前端 Mock 数据工具链:MSW 拦截与契约测试的工程化实践

发布时间:2026/7/12 20:16:49
前端 Mock 数据工具链:MSW 拦截与契约测试的工程化实践 前端 Mock 数据工具链MSW 拦截与契约测试的工程化实践一、后端接口还没好前端开发卡住了——前后端协作的天然瓶颈前后端分离后前端开发被后端接口的交付进度严重制约。常见的应对方案有三种在组件中硬编码假数据开发完还得删掉、搭建一个独立的 Mock Server需要额外维护、或者在每个 API 调用处写 if 判断走 mock 还是真实接口。这三种方案的共同问题是mock 数据和真实接口之间存在鸿沟——当后端接口真正交付后前端需要大量时间去适配差异。Mock Service WorkerMSW解决了前两个问题它在 Service Worker 层面拦截所有 HTTP 请求让你可以完全控制哪些接口走 mock、哪些走真实。而契约测试Contract Testing解决了第三个问题通过共享的 API Schema在 CI 中自动验证 mock 返回的数据格式是否与后端定义的接口契约一致。二、MSW 的原理与三层 Mock 策略graph TD A[React/Vue 组件] -- B[HTTP 请求br/fetch/axios] B -- C{MSW Service Worker} C --|注册了 handler| D[Mock Handler] C --|未注册 handler| E[真实网络请求] D -- F{Handler 逻辑} F --|静态数据| G[返回预设 JSON] F --|动态逻辑| H[处理请求参数br/返回对应数据] F --|透传| E subgraph 三个 Mock 层级 I[基础层br/SSOT: 单一真实数据源 JSON] J[业务层br/场景化的 handler] K[边界层br/错误、超时、空数据] end I -- F J -- F K -- FMSW 通过在public/mockServiceWorker.js中注入的 Service Worker 拦截浏览器发起的 HTTP 请求。这不是代理——请求不会离开浏览器不需要启动独立的进程。Service Worker 检查请求是否匹配已注册的 handler如果匹配就返回模拟数据否则透传到真实网络。三、MSW 工具链的完整工程化设置基础配置与 Handler 注册// mocks/handlers.ts — 接口 mock 定义 import { http, HttpResponse, delay } from msw; // 类型定义 interface User { id: string; name: string; email: string; role: admin | user; createdAt: string; } interface ApiError { code: string; message: string; } // 单一数据源——所有 handler 共享 const mockUsers: User[] [ { id: 1, name: 张三, email: zhangsanexample.com, role: admin, createdAt: 2026-01-15 }, { id: 2, name: 李四, email: lisiexample.com, role: user, createdAt: 2026-03-20 }, { id: 3, name: 王五, email: wangwuexample.com, role: user, createdAt: 2026-05-10 }, ]; export const handlers [ // 基础 CRUD http.get(/api/users, async ({ request }) { await delay(200); // 模拟网络延迟 const url new URL(request.url); const role url.searchParams.get(role); const filtered role ? mockUsers.filter(u u.role role) : mockUsers; return HttpResponse.json({ data: filtered, total: filtered.length }); }), http.get(/api/users/:id, async ({ params }) { await delay(100); const user mockUsers.find(u u.id params.id); if (!user) { return HttpResponse.json( { code: NOT_FOUND, message: 用户 ${params.id} 不存在 } satisfies ApiError, { status: 404 } ); } return HttpResponse.json({ data: user }); }), http.post(/api/users, async ({ request }) { await delay(300); const body await request.json() as PartialUser; // 模拟后端校验 if (!body.name || !body.email) { return HttpResponse.json( { code: VALIDATION_ERROR, message: name 和 email 为必填项 } satisfies ApiError, { status: 400 } ); } const newUser: User { id: String(mockUsers.length 1), name: body.name, email: body.email, role: body.role || user, createdAt: new Date().toISOString(), }; mockUsers.push(newUser); return HttpResponse.json({ data: newUser }, { status: 201 }); }), // 边界场景网络错误 http.get(/api/network-error, () { return HttpResponse.error(); }), // 边界场景服务器错误 http.get(/api/server-error, () { return HttpResponse.json( { code: INTERNAL_ERROR, message: 服务器内部错误 } satisfies ApiError, { status: 500 } ); }), // 边界场景超时 http.get(/api/timeout, async () { await delay(10000); // 10 秒超时 return HttpResponse.json({ data: [] }); }), ];浏览器端与 Node 端的统一集成// mocks/browser.ts — 浏览器端 Service Worker 启动 import { setupWorker } from msw/browser; import { handlers } from ./handlers; export const worker setupWorker(...handlers); // 启动 worker // worker.start({ // onUnhandledRequest: warn, // 未处理的请求打印警告 // });// mocks/server.ts — 测试环境Node 端的 Server import { setupServer } from msw/node; import { handlers } from ./handlers; export const server setupServer(...handlers); // 在 vitest/jest setup 文件中 // beforeAll(() server.listen({ onUnhandledRequest: error })); // afterEach(() server.resetHandlers()); // afterAll(() server.close());MSW Handler 的场景化管理// mocks/scenarios.ts — 按场景组织的 mock 数据 import { http, HttpResponse } from msw; import type { HttpHandler } from msw; // 场景1空数据状态 export const emptyScenario: HttpHandler[] [ http.get(/api/users, () { return HttpResponse.json({ data: [], total: 0 }); }), ]; // 场景2大量数据测试虚拟滚动 export const largeDatasetScenario: HttpHandler[] [ http.get(/api/users, () { const users Array.from({ length: 1000 }, (_, i) ({ id: String(i 1), name: 用户 ${i 1}, email: user${i 1}example.com, role: i % 10 0 ? admin : user, createdAt: new Date(2026, 0, i 1).toISOString(), })); return HttpResponse.json({ data: users, total: users.length }); }), ]; // 场景3错误状态用于测试错误处理 UI export const errorScenario: HttpHandler[] [ http.get(/api/users, () { return HttpResponse.json( { code: SERVICE_UNAVAILABLE, message: 服务暂时不可用 }, { status: 503 } ); }), http.get(/api/users/:id, () { return HttpResponse.json( { code: NOT_FOUND, message: 用户不存在 }, { status: 404 } ); }), ]; // 在测试中切换场景 // server.use(...errorScenario);契约测试——验证 Mock 与 API Schema 的一致性// contract-test/user-api.contract.test.ts import { describe, it, expect } from vitest; // 从共享的 API Schema 中导出类型 import type { User, ApiResponse, PaginatedResponse } from ../schemas/user.schema; describe(User API Contract, () { it(GET /api/users 返回格式应匹配 ApiResponsePaginatedResponseUser, async () { const response await fetch(/api/users); expect(response.status).toBe(200); const body: ApiResponsePaginatedResponseUser await response.json(); // 结构校验 expect(body).toHaveProperty(data); expect(Array.isArray(body.data)).toBe(true); expect(body).toHaveProperty(total); // 字段类型校验 if (body.data body.data.length 0) { const user body.data[0]; expect(typeof user.id).toBe(string); expect(typeof user.name).toBe(string); expect(typeof user.email).toBe(string); expect([admin, user]).toContain(user.role); // createdAt 应该是 ISO 8601 格式 expect(() new Date(user.createdAt)).not.toThrow(); } }); it(POST /api/users 422 错误响应格式应匹配 ApiError, async () { const response await fetch(/api/users, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({}), // 空 body 触发校验错误 }); expect(response.status).toBe(400); const body await response.json(); expect(body).toHaveProperty(code); expect(body).toHaveProperty(message); expect(typeof body.code).toBe(string); expect(typeof body.message).toBe(string); }); it(GET /api/users/:id 不存在的资源返回 404, async () { const response await fetch(/api/users/non-existent); expect(response.status).toBe(404); }); });共享 API Schema 定义// schemas/user.schema.ts — 前后端共享的接口类型定义 export interface User { id: string; name: string; email: string; role: admin | user; createdAt: string; // ISO 8601 } export interface ApiResponseT { data: T; } export interface PaginatedResponseT { data: T[]; total: number; } export interface ApiError { code: string; message: string; details?: Recordstring, string[]; }四、MSW 工具的边界未拦截请求、SSR 与调试未拦截请求的处理。MSW 默认会警告未命中 handler 的请求。在生产代码中混用 MSW 时可以通过onUnhandledRequest配置忽略静态资源和第三方请求。但测试环境应设置为error——如果一个测试发起了未拦截的请求说明你的测试依赖了外部环境破坏了测试的确定性。worker.start({ onUnhandledRequest(req, print) { // 忽略静态资源和第三方请求 if (req.url.includes(/node_modules/) || req.url.includes(cdn.example.com)) { return; } print.warning(); }, });SSR 环境不支持。MSW 的浏览器端依赖 Service WorkerNode 端依赖node-request-interceptor。但 Server-Side RenderingReact Server Components / Nuxt SSR在服务端执行时没有这些机制。目前 MSW 不支持在服务端渲染阶段拦截请求——这是 MSW 的明确边界不是 Bug。真实接口调试优先级。MSW 的价值在于开发前期。当后端接口可用后应该以真实接口为主MSW 仅保留边界场景超时、错误用于测试。不要让 MSW 成为真实接口的替代品——它是在接口不可用时的降级方案不是开发完成后的默认模式。五、总结MSW 契约测试的组合解决了前端开发中的两个核心问题Mock 数据与真实接口的格式一致性契约测试以及异常状态的模拟难度MSW 的 handler 可以返回任意 HTTP 状态和延迟。落地的三部曲搭建 MSW handler 的共享数据源单一真实来源按场景组织 handler 以支持不同开发阶段的需要建设契约测试确保 Mock 与 API Schema 的一致性。MSW 不只是造数据的工具而是前端独立开发能力的基石——它让你在只拿到接口文档的情况下就能跑通完整的前端交互流程。