根据 State of Frontend 2025 调查,67% 的开发团队将「前后端联调」列为项目延期的首要原因——接口没对齐、后端还没开发完、测试环境不稳定,这些问题日复一日地吞噬着开发效率。传统的解决方案是写一个静态 JSON 文件当 Mock,或者搭一个 Mock Server,但这些方案要么不够真实,要么维护成本高。Mock Service Worker (MSW) 用一种革命性的方式解决了这个问题:它在浏览器的 Service Worker 层拦截请求,让你的代码完全不知道自己在被 Mock——零侵入、零修改。再配合 Pact 的消费者驱动契约测试(Consumer-Driven Contract Testing),你就能保证 Mock 的行为和真实后端永远一致。
🔧 一、MSW 核心原理与浏览器端实战
1.1 为什么传统 Mock 方案都不够好?
在深入 MSW 之前,先看看我们踩过的坑:
| 方案 | 原理 | 优点 | 致命缺陷 |
|---|---|---|---|
| 静态 JSON 文件 | 手动维护 mock 数据 | 简单 | 数据脱离真实 API,接口变更时容易忘记更新 |
| 本地 Mock Server | 启动一个 Express/JSON Server | 灵活 | 需要额外进程、端口冲突、环境变量切换 |
| 代码内 Mock | jest.mock('axios') |
精确控制 | Mock 逻辑侵入业务代码,无法在浏览器中使用 |
| Proxy 代理 | webpack-dev-server proxy | 透明 | 依赖后端环境,后端挂了你也挂 |
⚠️ **警告:**上面这些方案的核心问题是——Mock 逻辑和真实请求走的是两条路径。你的代码在 Mock 环境下通过测试,不代表生产环境也能正常工作。
MSW 的核心创新在于:它在网络层拦截请求,而不是在代码层替换函数。浏览器发出的真实 fetch / XMLHttpRequest 请求会被 Service Worker 拦截,返回你定义的响应。你的业务代码完全不知道自己在被 Mock——这就是所谓的「网络层 Mock」。
1.2 MSW 浏览器端完整配置
第一步:安装与初始化
# 安装 MSW
npm install msw --save-dev
# 生成 Service Worker 文件(只需执行一次)
npx msw init public/ --save
💡 提示:
--save参数会把 init 命令写入package.json的postinstall脚本,确保团队成员安装依赖后自动同步 Service Worker 文件。
第二步:定义请求处理器(Handlers)
// src/mocks/handlers.js
// 定义所有需要 Mock 的 API 端点
import { http, HttpResponse, delay } from 'msw'
// 模拟数据库
const users = [
{ id: 1, name: '张三', email: 'zhangsan@example.com', role: 'admin' },
{ id: 2, name: '李四', email: 'lisi@example.com', role: 'user' },
{ id: 3, name: '王五', email: 'wangwu@example.com', role: 'user' },
]
export const handlers = [
// ✅ GET 请求:获取用户列表(支持分页和搜索)
http.get('/api/users', async ({ request }) => {
const url = new URL(request.url)
const page = parseInt(url.searchParams.get('page') || '1')
const pageSize = parseInt(url.searchParams.get('pageSize') || '10')
const keyword = url.searchParams.get('keyword') || ''
await delay(200) // 模拟网络延迟
let filtered = users
if (keyword) {
filtered = users.filter(u =>
u.name.includes(keyword) || u.email.includes(keyword)
)
}
const start = (page - 1) * pageSize
const paged = filtered.slice(start, start + pageSize)
return HttpResponse.json({
code: 0,
data: {
list: paged,
total: filtered.length,
page,
pageSize,
},
})
}),
// ✅ GET 请求:获取单个用户(路径参数)
http.get('/api/users/:id', async ({ params }) => {
await delay(100)
const user = users.find(u => u.id === parseInt(params.id))
if (!user) {
return HttpResponse.json(
{ code: 404, message: '用户不存在' },
{ status: 404 }
)
}
return HttpResponse.json({ code: 0, data: user })
}),
// ✅ POST 请求:创建用户(模拟验证错误)
http.post('/api/users', async ({ request }) => {
const body = await request.json()
if (!body.name || !body.email) {
return HttpResponse.json(
{ code: 400, message: '名称和邮箱为必填项' },
{ status: 400 }
)
}
const newUser = {
id: users.length + 1,
...body,
createdAt: new Date().toISOString(),
}
users.push(newUser)
return HttpResponse.json({ code: 0, data: newUser }, { status: 201 })
}),
// ✅ 模拟网络错误场景
http.get('/api/flaky-endpoint', () => {
return HttpResponse.error()
}),
]
第三步:启动 Mock 服务
// src/mocks/browser.js
// 浏览器环境入口
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
// src/main.js(应用入口)
// 只在开发环境启动 Mock
async function bootstrap() {
if (import.meta.env.DEV) {
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: 'warn', // 未处理的请求打印警告
serviceWorker: {
url: '/mockServiceWorker.js',
},
})
}
// 启动你的应用(Vue / React / 等)
createApp(App).mount('#app')
}
bootstrap()
📌 **记住:**MSW 不会修改你的
fetch或axios代码。你的 API 调用代码在有 Mock 和没有 Mock 时完全一样——这才是 MSW 的核心价值。
1.3 与 Vue 3 / React 集成的最佳实践
MSW 与框架无关,但在实际项目中,你通常需要根据环境变量控制 Mock 的开关:
// vite.config.js
// 通过环境变量控制 Mock 开关
export default defineConfig({
define: {
__ENABLE_MOCK__: process.env.ENABLE_MOCK === 'true',
},
})
// .env.development
ENABLE_MOCK=true
VITE_API_BASE_URL=/api
// .env.production
ENABLE_MOCK=false
VITE_API_BASE_URL=https://api.example.com
这种方案的优势是:同一套代码,同一套接口定义,Mock 和真实 API 之间切换只需要改一个环境变量。
🧪 二、Node.js 端集成测试与场景覆盖
2.1 MSW 在 Vitest 中的配置
浏览器端 Mock 用于开发调试,Node.js 端 Mock 用于自动化测试。MSW 同时支持两个环境:
// src/mocks/server.js
// Node.js 环境入口(用于测试)
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// vitest.setup.ts
// 测试全局配置
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'
// 启动 Mock Server
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
// 每个测试后重置处理器(避免测试间相互影响)
afterEach(() => server.resetHandlers())
// 所有测试结束后关闭
afterAll(() => server.close())
2.2 测试中的高级用法:动态覆盖处理器
在测试中,你经常需要模拟不同的后端响应(成功、失败、超时)。MSW 的 server.use() 方法让你在单个测试中临时覆盖默认行为:
// user.test.ts
// 测试不同 API 响应场景
import { describe, it, expect } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { server } from './mocks/server'
import { fetchUser, createUser } from './api/user'
describe('用户 API', () => {
it('正常获取用户', async () => {
const user = await fetchUser(1)
expect(user.name).toBe('张三')
})
it('用户不存在时返回 404', async () => {
// 临时覆盖处理器:模拟 404 响应
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ code: 404, message: '用户不存在' },
{ status: 404 }
)
})
)
await expect(fetchUser(999)).rejects.toThrow('404')
})
it('网络超时时的降级处理', async () => {
// 临时覆盖:模拟 5 秒超时
server.use(
http.get('/api/users/:id', async () => {
await delay(5000)
return HttpResponse.json({ code: 0, data: {} })
})
)
// 你的代码应该有超时处理逻辑
const result = await fetchUser(1, { timeout: 3000 })
expect(result).toBeNull() // 超时返回 null(降级策略)
})
it('服务端 500 错误时的重试逻辑', async () => {
let callCount = 0
server.use(
http.get('/api/users/:id', () => {
callCount++
if (callCount < 3) {
return HttpResponse.json(
{ code: 500, message: 'Internal Server Error' },
{ status: 500 }
)
}
// 第三次调用成功
return HttpResponse.json({
code: 0,
data: { id: 1, name: '张三' },
})
})
)
const user = await fetchUser(1, { retry: 3 })
expect(user.name).toBe('张三')
expect(callCount).toBe(3) // 验证确实重试了
})
})
⚠️ 警告:
server.resetHandlers()会清除所有server.use()的临时覆盖,但不会清除初始化时传入的handlers。务必在afterEach中调用,否则测试之间会相互污染。
2.3 MSW 处理器的组织架构
当项目规模增长后,把所有 handler 放在一个文件里会变得难以维护。推荐按领域模块拆分:
src/mocks/
├── index.ts # 统一导出
├── browser.ts # 浏览器入口
├── server.ts # Node.js 入口
└── handlers/
├── auth.ts # 认证相关
├── users.ts # 用户相关
├── products.ts # 商品相关
└── payments.ts # 支付相关
// src/mocks/index.ts
// 统一收集所有 handler
import { authHandlers } from './handlers/auth'
import { userHandlers } from './handlers/users'
import { productHandlers } from './handlers/products'
import { paymentHandlers } from './handlers/payments'
export const handlers = [
...authHandlers,
...userHandlers,
...productHandlers,
...paymentHandlers,
]
📋 三、Pact 消费者驱动契约测试
3.1 为什么有了 MSW 还需要契约测试?
MSW 解决了「前端开发不依赖后端」的问题,但它有一个致命弱点:你定义的 Mock 数据是你自己猜的。如果后端改了接口字段名、改了返回格式,你的 Mock 还是旧的——直到联调时才发现不一致。
消费者驱动契约测试(Consumer-Driven Contract Testing,CDCT) 的思路是:由前端(消费者)定义自己期望的接口格式,后端(提供者)验证自己的 API 满足所有消费者的期望。
前端测试 → 生成契约文件(Pact 文件)→ 上传到 Pact Broker
↓
后端测试 ← 从 Pact Broker 下载契约 ← 验证 API 是否满足契约
3.2 前端:定义消费者契约
// tests/contract/user.consumer.pact.test.ts
// 前端作为消费者,定义期望的 API 契约
import { Pact } from '@pact-foundation/pact'
import path from 'path'
import { fetchUser, createUser } from '../../src/api/user'
const provider = new Pact({
consumer: 'FrontendApp',
provider: 'UserAPI',
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
})
describe('用户 API 契约', () => {
beforeAll(() => provider.setup())
afterAll(() => provider.finalize())
describe('GET /api/users/:id', () => {
beforeAll(async () => {
await provider.addInteraction({
state: '用户 ID 1 存在',
uponReceiving: '获取用户详情的请求',
withRequest: {
method: 'GET',
path: '/api/users/1',
headers: {
Accept: 'application/json',
Authorization: 'Bearer test-token',
},
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
code: 0,
data: {
id: provider.like(1), // 类型匹配,不要求精确值
name: provider.like('张三'), // 字符串类型
email: provider.term({ // 正则匹配
generate: 'test@example.com',
matcher: '^[\\w.-]+@[\\w.-]+\\.\\w+$',
}),
role: provider.stringMatching(/^(admin|user|moderator)$/),
},
},
},
})
})
it('返回符合契约的用户数据', async () => {
const user = await fetchUser(1, {
baseUrl: provider.mockService.baseUrl,
token: 'test-token',
})
expect(user.id).toBeDefined()
expect(user.name).toBeDefined()
expect(user.email).toMatch(/@/)
expect(['admin', 'user', 'moderator']).toContain(user.role)
})
})
describe('POST /api/users', () => {
beforeAll(async () => {
await provider.addInteraction({
state: '系统允许创建新用户',
uponReceiving: '创建用户的请求',
withRequest: {
method: 'POST',
path: '/api/users',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: {
name: '新用户',
email: 'new@example.com',
},
},
willRespondWith: {
status: 201,
body: {
code: 0,
data: {
id: provider.like(4),
name: provider.like('新用户'),
email: provider.like('new@example.com'),
createdAt: provider.iso8601DateTime(),
},
},
},
})
})
it('创建用户成功', async () => {
const result = await createUser(
{ name: '新用户', email: 'new@example.com' },
{ baseUrl: provider.mockService.baseUrl, token: 'test-token' }
)
expect(result.id).toBeDefined()
expect(result.createdAt).toBeDefined()
})
})
})
运行测试后,Pact 会在 pacts/ 目录生成一个 JSON 契约文件:
{
"consumer": { "name": "FrontendApp" },
"provider": { "name": "UserAPI" },
"interactions": [
{
"description": "获取用户详情的请求",
"providerState": "用户 ID 1 存在",
"request": {
"method": "GET",
"path": "/api/users/1"
},
"response": {
"status": 200,
"body": {
"code": 0,
"data": { "id": 1, "name": "张三", "email": "test@example.com" }
}
}
}
]
}
3.3 后端:验证提供者契约
// tests/contract/user.provider.pact.test.js
// 后端作为提供者,验证是否满足前端的契约
import { Verifier } from '@pact-foundation/pact'
import { startServer } from '../../src/server'
describe('User API Pact 验证', () => {
let server
beforeAll(async () => {
server = await startServer({ port: 3001 })
})
afterAll(async () => {
await server.close()
})
it('满足所有消费者的契约', async () => {
await new Verifier({
providerBaseUrl: 'http://localhost:3001',
pactUrls: ['./pacts/FrontendApp-UserAPI.json'],
stateHandlers: {
'用户 ID 1 存在': async () => {
// 在测试数据库中插入测试数据
await db.users.insert({ id: 1, name: '张三', email: 'zhangsan@example.com' })
},
'系统允许创建新用户': async () => {
// 确保测试环境允许创建用户
await db.users.deleteMany({})
},
},
logLevel: 'warn',
}).verifyProvider()
})
})
⚠️ **警告:**不要把 Pact 契约文件提交到 Git。应该通过 Pact Broker 在 CI/CD 流水线中自动交换。在
.gitignore中添加pacts/目录。
3.4 MSW + Pact 协作架构
MSW 和 Pact 不是互斥的,而是互补的。它们在不同阶段发挥作用:
| 阶段 | 工具 | 作用 | 谁验证 |
|---|---|---|---|
| 本地开发 | MSW | 前端独立开发,不依赖后端 | 开发者自己 |
| 单元/集成测试 | MSW | 快速测试各种 API 响应场景 | 自动化测试 |
| 契约测试 | Pact | 保证前后端接口格式一致 | CI/CD 流水线 |
| E2E 测试 | 真实 API | 验证完整链路 | 测试环境 |
💡 四、CI/CD 集成与生产最佳实践
4.1 GitHub Actions 集成
# .github/workflows/contract-test.yml
name: 契约测试
on:
pull_request:
branches: [main]
jobs:
consumer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
# 运行消费者测试,生成 Pact 文件
- run: npm run test:pact:consumer
# 上传到 Pact Broker
- run: |
npx pact-broker publish ./pacts \
--consumer-app-version=${{ github.sha }} \
--branch=${{ github.head_ref }} \
--broker-base-url=$PACT_BROKER_URL \
--broker-token=$PACT_BROKER_TOKEN
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
provider:
runs-on: ubuntu-latest
needs: consumer
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
# 启动后端服务
- run: npm run start:test &
- run: sleep 5
# 从 Pact Broker 获取契约并验证
- run: npm run test:pact:provider
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
PACT_CONSUMER_TAG: ${{ github.head_ref }}
4.2 常见坑点与避坑指南
坑点 1:MSW 在生产构建中被打包
// ❌ 错误:MSW 被打包到生产 bundle 中
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
const worker = setupWorker(...handlers)
worker.start()
// ✅ 正确:通过环境变量和动态导入确保生产环境不含 MSW
if (import.meta.env.DEV) {
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: 'bypass', // 未匹配的请求直接放行
})
}
💡 **提示:**在 Vite 中,
import.meta.env.DEV在生产构建时会被tree-shake移除整个import()表达式。如果你用 Webpack,需要配合DefinePlugin和IgnorePlugin。
坑点 2:Pact 契约过于严格
// ❌ 错误:期望精确值(后端数据变了就失败)
body: {
id: 1,
name: '张三',
createdAt: '2026-05-30T10:00:00Z',
}
// ✅ 正确:使用匹配器(只要求类型和格式)
body: {
id: provider.like(1),
name: provider.like('张三'),
createdAt: provider.iso8601DateTime(),
}
坑点 3:测试间数据污染
// ❌ 错误:handler 中使用闭包变量,测试间共享状态
let counter = 0
const handlers = [
http.get('/api/count', () => {
counter++
return HttpResponse.json({ count: counter })
}),
]
// ✅ 正确:每个 handler 独立,使用 request 信息决定响应
const handlers = [
http.get('/api/count', ({ request }) => {
const url = new URL(request.url)
const seed = url.searchParams.get('seed') || '0'
return HttpResponse.json({ count: parseInt(seed) })
}),
]
4.3 性能与团队协作建议
MSW Handler 的懒加载:当 handler 超过 30 个时,建议按路由模块动态加载,减少初始化时间。
Pact Broker 的选型:小团队可以直接用 Pactflow(SaaS 版,免费层足够 5 人团队使用);大团队自建 Pact Broker(Docker 一键部署)。
渐进式落地策略:
- ✅ 第一步:给核心 API(登录、支付、主流程)添加 MSW Mock
- ✅ 第二步:将 MSW handler 的数据结构同步到 Pact 契约
- ✅ 第三步:在 CI 中加入契约验证,阻断不兼容的接口变更
- ❌ 避免:一次性给所有 API 做 Mock,维护成本会压垮团队
🎯 总结
API Mock 和契约测试不是「锦上添花」,而是大型项目前后端协作的基础设施。MSW 在开发和测试阶段提供零侵入的网络层 Mock,Pact 在 CI/CD 阶段保证前后端接口契约一致。两者结合,形成从开发到部署的完整解耦方案。
⚡ **关键结论:**MSW 解决的是「开发效率」问题——让前端不等后端;Pact 解决的是「接口一致性」问题——让前后端变更可追溯。两者缺一不可。
相关工具推荐:
- 🔧 MSW(https://mswjs.io)— 网络层 Mock 标杆
- 🔧 Pact(https://docs.pact.io)— 消费者驱动契约测试框架
- 🔧 Pactflow(https://pactflow.io)— Pact Broker 的 SaaS 版本
- 🔧 Mirage JS(https://miragejs.com)— 另一种 API Mock 方案(ORM 风格,适合复杂数据关系)
- 🔧 json-server(https://github.com/typicode/json-server)— 快速原型 Mock(适合简单 CRUD)