用 Claude Code 搭建 MSW API Mock 的实战指南
用 Claude Code 和 MSW 搭建可复用的 API Mock、Node 测试与 CI 契约检查。
MSW 是 Mock Service Worker 的缩写,可以在浏览器里用 Service Worker 拦截 HTTP 请求,也可以在 Node.js 测试环境里拦截 fetch 请求。它的价值不是“随便造一个假数据”,而是把真实 API 的状态码、JSON 结构、认证错误、分页、网络失败都固定成可重复验证的契约。
Claude Code 很适合帮你搭 MSW,但不能只写一句“帮我做 API mock”。如果目标说得太模糊,它往往会生成看起来能跑、实际却和后端契约对不上的 mock。本文会把浏览器预览、Vitest 单元测试、错误响应、CI 检查和 Claude Code 的提示词放在同一套流程里,适合前端团队在后端接口未完成、或者想把 API 变更提前挡在 PR 阶段时使用。
本文使用 MSW 2 的 http、HttpResponse、setupWorker 和 setupServer。实现前请先确认一次官方文档:MSW Quick start、Browser integration、Node.js integration、error responses、network errors。相关测试流程也可以参考Vitest 高级测试技巧、Playwright E2E 测试、API 测试自动化和CI/CD 设置。
适用场景
MSW 最适合放在“前端需要稳定接口,但真实 API 还在变化”的阶段。它能让产品列表、详情页、认证状态、空状态、权限错误、分页边界和网络失败都在本地复现。
| 场景 | 应该 mock 什么 | 不该省略什么 |
|---|---|---|
| 后端接口未完成 | 列表、详情、创建、空状态 | 真实 API 的字段名称和状态码 |
| 认证和权限测试 | 401、403、角色差异 | 普通用户看到的限制 |
| 故障演练 | 500、超时、网络失败、重试 | loading、错误文案和重试按钮 |
| CI 契约检查 | JSON 形状、必填字段、分页元数据 | API 改动对前端的破坏 |
给 Claude Code 的任务应该具体到文件、环境和完成条件:
请用 MSW 2 为用户 API 建立 mock。
目标:
- 浏览器开发环境和 Vitest Node 环境共用同一个 handlers.ts
- 覆盖认证、分页、创建用户、401、404、500、网络失败
- TypeScript 类型不能用 any
- 给出可复制运行的测试和 CI 命令
限制:
- 不改真实 API client 的公开接口
- 不把 mock 数据写进组件内部
文件结构
建议先把 mock 处理器集中在一个入口里。浏览器用 setupWorker,测试用 setupServer,两者复用同一个 handlers.ts。
flowchart LR
UI["浏览器页面"] --> Worker["setupWorker"]
Test["Vitest / CI"] --> Server["setupServer"]
Worker --> Handlers["handlers.ts"]
Server --> Handlers
Handlers --> Contract["API 契约: status / JSON / auth / delay"]
安装依赖并生成 Service Worker 文件:
npm i -D msw vitest typescript
npx msw init public/ --save
开发服务器启动后,先打开 http://localhost:5173/mockServiceWorker.js。如果这里是 404,浏览器端的 MSW 根本没有被注册成功。很多“为什么请求没有被拦截”的问题,其实都卡在这个文件没有被正确公开。
可直接复用的 handlers
下面的示例覆盖用户列表、创建用户、认证、分页、校验、404 和 500。URL 使用绝对地址,方便 Node 的 fetch 测试。
import { delay, http, HttpResponse } from "msw";
export const API_ORIGIN = "https://api.example.test";
type Role = "admin" | "editor" | "viewer";
export type User = {
id: string;
name: string;
email: string;
role: Role;
};
type CreateUserInput = {
name: string;
email: string;
role?: Role;
};
type ErrorBody = {
error: {
code: string;
message: string;
requestId: string;
};
};
type PageMeta = {
total: number;
page: number;
perPage: number;
};
type UserListResponse = {
data: User[];
meta: PageMeta;
};
const seedUsers: User[] = [
{ id: "u_1", name: "Aki Tanaka", email: "aki@example.com", role: "admin" },
{ id: "u_2", name: "Bea Sato", email: "bea@example.com", role: "editor" },
{ id: "u_3", name: "Cal Mori", email: "cal@example.com", role: "viewer" },
];
let users: User[] = [...seedUsers];
const jsonError = (status: number, code: string, message: string) =>
HttpResponse.json(
{ error: { code, message, requestId: "req_mock_001" } satisfies ErrorBody },
{ status }
);
const requireAuth = (request: Request) => {
const token = request.headers.get("authorization");
return token === "Bearer demo-token"
? null
: jsonError(401, "UNAUTHORIZED", "Missing or invalid bearer token");
};
const isRole = (value: string | null): value is Role =>
value === "admin" || value === "editor" || value === "viewer";
export function resetMockData() {
users = [...seedUsers];
}
export const handlers = [
http.get(`${API_ORIGIN}/users`, async ({ request }) => {
const authError = requireAuth(request);
if (authError) return authError;
const url = new URL(request.url);
const role = url.searchParams.get("role");
const page = Number(url.searchParams.get("page") ?? "1");
const perPage = Number(url.searchParams.get("perPage") ?? "20");
if (role !== null && !isRole(role)) {
return jsonError(400, "INVALID_ROLE", "role must be admin, editor, or viewer");
}
const filtered = role ? users.filter((user) => user.role === role) : users;
const start = (page - 1) * perPage;
const data = filtered.slice(start, start + perPage);
await delay(80);
return HttpResponse.json<UserListResponse>({
data,
meta: { total: filtered.length, page, perPage },
});
}),
http.post(`${API_ORIGIN}/users`, async ({ request }) => {
const authError = requireAuth(request);
if (authError) return authError;
const input = (await request.json()) as Partial<CreateUserInput>;
if (!input.name || !input.email) {
return jsonError(422, "VALIDATION_ERROR", "name and email are required");
}
const user: User = {
id: `u_${users.length + 1}`,
name: input.name,
email: input.email,
role: input.role ?? "viewer",
};
users = [...users, user];
return HttpResponse.json(user, { status: 201 });
}),
http.get(`${API_ORIGIN}/users/:id`, ({ params, request }) => {
const authError = requireAuth(request);
if (authError) return authError;
const user = users.find((item) => item.id === params.id);
return user ?? jsonError(404, "USER_NOT_FOUND", "User was not found");
}),
http.get(`${API_ORIGIN}/unstable`, () => {
return jsonError(500, "UPSTREAM_FAILURE", "Temporary upstream failure");
}),
];
到这一步后,应该让 Claude Code 再检查一次 data 和 meta 的结构、错误格式、状态码与认证 header。如果真实后端契约写在 OpenAPI 或 README 里,就把它作为输入,让 Claude Code 列出 mock 与真实契约之间的差异。
浏览器端入口
浏览器端只在开发环境启动 MSW。为了避免 mock 代码进入生产行为,入口处要用环境变量明确分支。
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
export async function enableMocking() {
if (import.meta.env.PROD || import.meta.env.VITE_API_MOCKING !== "enabled") {
return;
}
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
}
本地开发时,有些尚未 mock 的 API 可能需要继续请求真实后端,因此可以先用 bypass。但 CI 中应该避免漏掉未处理请求。测试侧建议使用 onUnhandledRequest: "error",让没有被 mock 的 API 调用直接失败。
用 Vitest 固定 API 契约
测试环境使用 setupServer。每个测试结束后调用 resetHandlers() 和 resetMockData(),避免临时覆盖的 handler 或新增数据污染下一个测试。
import { afterAll, afterEach, beforeAll } from "vitest";
import { setupServer } from "msw/node";
import { handlers, resetMockData } from "./handlers";
export const server = setupServer(...handlers);
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.resetHandlers();
resetMockData();
});
afterAll(() => {
server.close();
});
如果 API client 只是很薄的一层函数,直接用 fetch 验证也足够。
import { describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { API_ORIGIN } from "./handlers";
import { server } from "./test-server";
const authed = (url: string, init?: RequestInit) =>
fetch(url, {
...init,
headers: {
authorization: "Bearer demo-token",
"content-type": "application/json",
...init?.headers,
},
});
describe("users API mock", () => {
it("returns paginated users", async () => {
const response = await authed(`${API_ORIGIN}/users?page=1&perPage=2`);
const body = (await response.json()) as {
data: Array<Record<string, unknown>>;
meta: Record<string, unknown>;
};
expect(response.status).toBe(200);
expect(body.data).toHaveLength(2);
expect(body.meta).toMatchObject({ total: 3, page: 1, perPage: 2 });
});
it("rejects missing auth", async () => {
const response = await fetch(`${API_ORIGIN}/users`);
const body = (await response.json()) as { error: { code: string } };
expect(response.status).toBe(401);
expect(body.error.code).toBe("UNAUTHORIZED");
});
it("simulates a network failure for retry UI", async () => {
server.use(
http.get(`${API_ORIGIN}/users`, () => {
return HttpResponse.error();
})
);
await expect(authed(`${API_ORIGIN}/users`)).rejects.toThrow();
});
it("guards against response contract drift", async () => {
const response = await authed(`${API_ORIGIN}/users`);
const body = (await response.json()) as {
data: Array<Record<string, unknown>>;
meta: Record<string, unknown>;
};
expect(Object.keys(body.data[0]).sort()).toEqual(["email", "id", "name", "role"]);
expect(body.data[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
email: expect.stringContaining("@"),
})
);
expect(body.meta).toEqual(expect.objectContaining({ page: 1, perPage: 20 }));
});
});
放进 CI 的最小设置
在 PR 中使用 vitest run,避免进入 watch 模式。只跑 mock 契约测试通常几秒就能完成,很适合作为第一道质量门槛。
name: msw-contract
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test -- --run
常见失败
第一个失败是 mockServiceWorker.js 没有被公开。浏览器控制台会显示 404,结果就是所有请求都直接打到真实 API。
第二个失败是测试之间互相污染。每个测试都要执行 server.resetHandlers() 和 resetMockData(),否则上一个测试临时覆盖的 500 响应会影响下一个测试。
第三个失败是在 CI 中使用 onUnhandledRequest: "bypass"。本地为了开发方便可以 bypass,但 CI 应该用 error,否则新增接口没有 mock 也不会被发现。
第四个失败是只检查“有没有数据显示”,没有检查错误结构。真实系统中最常坏的是认证、权限、限流、超时、重试和表单校验,所以测试必须断言 error.code、meta.total、状态码和重试行为。
第五个失败是把 mock 数据散落在组件里。这样短期看很快,后期却无法让 Playwright、Vitest 和 Storybook 共用同一份契约。把数据、handler 和 reset 函数集中在一个 mock 模块里,维护成本会低很多。
收益导线 CTA
MSW 的价值不只在测试代码。对于 ClaudeCodeLab 这样的内容站,文章 CTA、商品购买、咨询表单和免费资源下载都依赖 API 与外部服务。如果这些入口在上线前没有 mock 500、网络失败、认证过期和字段变更,转化率很容易被小错误吃掉。需要把 Claude Code 的提示词、审查清单和 API mock 模板整理成团队资产时,可以查看产品列表或Claude Code 培训。
实测结果
Masa 在整理这篇 MSW 模板时,最有价值的检查不是“列表能显示”,而是 HttpResponse.error()、onUnhandledRequest: "error"、401、422 和 meta.total 这些失败路径。把浏览器开发和本地 CI 使用同一组 handler 后,Claude Code 修改 API client 时更容易发现字段漂移。结论是:先把契约写进 MSW,再让 Claude Code 生成组件和测试,比先写 UI 再补 mock 稳定得多。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。