集成测试实战:从踩坑到高效验证的完整指南
为什么我非要折腾集成测试方案?
说实话,我一开始对集成测试是有点抵触的。单元测试写得飞起,E2E 用 Cypress 跑个冒烟也行,但“集成测试”这四个字总让我觉得模糊又鸡肋。直到上个项目,一个看似简单的登录流程,因为 mock 太过理想化,上线后才发现和真实后端交互时 token 过期逻辑完全没覆盖,用户直接卡在空白页。那一刻我才意识到:单元测得太细,E2E 又太重,中间缺的正是能模拟真实接口调用、又不依赖完整 UI 渲染的那一环——也就是集成测试。
于是最近半年,我认真试了三种主流方案:Jest + MSW(Mock Service Worker)、Cypress Component Testing(带网络拦截),还有 Vitest + 自定义 mock 模块。今天就来聊聊我的真实体验,不讲大道理,只说踩过的坑和省下的时间。
谁更灵活?谁更省事?
先说结论:我比较喜欢用 Jest + MSW。不是因为它最强大,而是它最“刚刚好”——既能拦截真实 fetch 请求,又能用接近生产环境的方式写测试,还不用启动整个浏览器。
举个例子,我们有个获取用户信息的接口,需要测试 token 过期时的重定向逻辑:
// api/user.js
export const fetchUser = async () => {
const res = await fetch('/api/user');
if (res.status === 401) {
window.location.href = '/login';
return null;
}
return res.json();
};
用 MSW 的话,mock 服务端行为非常直观:
// test/user.test.js
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { fetchUser } from '../api/user';
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.status(401));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('should redirect to login when 401', () => {
// 模拟 location 跳转
delete global.window.location;
global.window.location = { href: '' };
fetchUser();
expect(global.window.location.href).toBe('/login');
});
这段代码亲测有效,而且不需要启动任何 UI 框架,纯 Node 环境就能跑。MSW 的优势在于它用 Service Worker 的思路在 Node 和浏览器里统一了 mock 逻辑,写一次,前后端都能用。我特别喜欢它用 ctx.status()、ctx.json() 这种声明式方式控制响应,比手写 mock 函数清晰多了。
Cypress Component Testing:重,但看得见
当然,如果你项目里已经重度依赖 Cypress,那它的 Component Testing 模式也值得考虑。尤其是当你需要验证“组件在特定 API 响应下是否渲染正确”时,它能直接把组件挂到 DOM 上,所见即所得。
同样的场景,用 Cypress 写大概是这样:
// cypress/component/UserProfile.cy.jsx
import UserProfile from './UserProfile';
import { fetchUser } from '../../api/user';
cy.intercept('GET', '/api/user', {
statusCode: 401,
body: {}
}).as('getUser');
cy.mount(<UserProfile />);
cy.wait('@getUser');
// 验证是否跳转 —— 但这里有个坑!
cy.url().should('include', '/login');
看起来挺美,但实际用起来有几个问题:第一,Cypress 的 cy.intercept 对 fetch 支持没问题,但如果你用了 axios 或自定义封装,得确保底层还是走原生 fetch/XHR;第二,跳转验证在 component test 里其实很别扭,因为 Cypress 默认不会真的跳转页面,你得额外配置 experimentalSingleTabRunMode 或者用 window.location.replace 监听,折腾了半天发现不如直接跑 E2E 测试。
所以我的结论是:Cypress Component Testing 适合 UI 渲染逻辑复杂的集成测试,比如“加载中状态 → 成功 → 失败”的完整状态流,但涉及路由跳转、全局副作用的场景,反而成了负担。
Vitest + 手动 mock:轻量,但容易翻车
有些团队追求极致速度,会选 Vitest + 手动 mock 模块。比如这样:
// test/user.test.js
import { vi } from 'vitest';
import { fetchUser } from '../api/user';
vi.mock('../api/user', () => {
return {
fetchUser: vi.fn().mockResolvedValue(null)
};
});
// 或者更暴力地 mock 全局 fetch
global.fetch = vi.fn().mockResolvedValue({
status: 401,
json: async () => ({})
});
这种方式跑得飞快,Vitest 本身启动也快。但问题在于:mock 太“假”了。你模拟的是函数返回值,而不是真实的 HTTP 层。一旦你的业务逻辑里有 res.status、res.headers 判断,或者用了 AbortController、timeout 等 fetch 特性,手动 mock 很容易漏掉细节,导致测试通过但线上崩了。
我之前就踩过一个坑:本地 mock 返回 { data: user },但实际接口返回的是 { user: { ... } },因为结构不同,解构时报错,但测试完全没发现问题。从那以后,我就尽量避免这种“过度简化”的 mock。
我的选型逻辑
现在我做新项目,集成测试基本按这个逻辑选:
- 如果测试重点是“API 响应对业务逻辑的影响”(比如错误码处理、重试机制、缓存策略),我首选 Jest + MSW。它模拟的是网络层,不是函数层,更贴近真实。
- 如果测试重点是“组件在各种数据下的 UI 表现”,比如表格加载、错误提示样式、骨架屏切换,我会用 Cypress Component Testing,配合
cy.intercept拦截接口,眼见为实。 - Vitest + 手动 mock 我基本不用了,除非是纯工具函数的集成(比如一个依赖多个 util 的函数),但那其实更接近单元测试。
另外,这里注意我踩过好几次坑:MSW 在 Node 环境下默认不会处理相对路径(比如 /api/user),需要配 setupServer 的 onUnhandledRequest 选项,否则容易静默失败。建议加上:
setupServer({
onUnhandledRequest: 'error' // 未 mock 的请求直接报错,避免漏测
});
最后一点现实主义
其实没有完美的方案。MSW 也有缺点:比如在测试中修改 handler 逻辑后,有时候需要 server.resetHandlers() 才能生效,否则会复用上一个测试的 mock;Cypress 虽然重,但它的 time travel 调试确实香。而 Vitest 快是快,但“快”不能替代“准”。
我现在的做法是:核心业务流程(登录、支付、提交)用 Jest + MSW 写集成测试,覆盖所有边界情况;UI 交互复杂的功能用 Cypress Component Test 补充;其他地方,能单元测试就单元测试,实在不行就靠 E2E 兜底。
以上是我个人对集成测试方案的完整对比和选型思路,有更优的实现方式欢迎评论区交流。毕竟,能稳稳上线的测试,才是好测试。

暂无评论