6.2 Node 环境与 Vitest 单元测试
MSW 最大的价值之一:浏览器开发用的那套 handler,在 Node 测试里原样运行。没有 vi.mock,没有接口适配层,测试发的也是真实的 fetch。
setupServer
ts
// tests/server.ts
import { setupServer } from "msw/node";
import { handlers } from "../src/mocks/handlers";
export const server = setupServer(...handlers);Vitest 的 setup 文件管理生命周期:
ts
// vitest.setup.ts
import { server } from "./tests/server";
beforeAll(() => server.listen({ onUnhandledFrame: "error" })); // 未声明的请求一律失败
afterEach(() => server.resetHandlers()); // 用例间隔离
afterAll(() => server.close());ts
// vitest.config.ts
export default defineConfig({
test: {
environment: "node", // 测纯逻辑/服务代码用 node;测组件时用 jsdom / happy-dom
setupFiles: ["./vitest.setup.ts"],
},
});测试组件时的三种数据策略
ts
import { http, HttpResponse } from "msw";
import { server } from "./tests/server";
test("成功态:渲染用户列表", async () => {
server.use(
http.get("*/api/users", () =>
HttpResponse.json({ data: [{ id: "1", name: "小邮差" }], total: 1 }),
),
);
render(<UserList />);
expect(await screen.findByText("小邮差")).toBeInTheDocument();
});
test("空态:渲染暂无数据", async () => {
server.use(http.get("*/api/users", () => HttpResponse.json({ data: [], total: 0 })));
render(<UserList />);
expect(await screen.findByText("暂无数据")).toBeInTheDocument();
});
test("错误态:500 时显示重试按钮", async () => {
server.use(http.get("*/api/users", () => HttpResponse.json({ error: {} }, { status: 500 })));
render(<UserList />);
expect(await screen.findByRole("button", { name: "重试" })).toBeInTheDocument();
});
test("加载态:慢接口下 loading 不闪烁", async () => {
server.use(
http.get("*/api/users", async () => {
await delay(800);
return HttpResponse.json({ data: [], total: 0 });
}),
);
render(<UserList />);
expect(screen.getByText("加载中…")).toBeInTheDocument();
});关键模式:默认 handler 提供"快乐路径",个别用例用 server.use() 覆盖,afterEach(resetHandlers) 保证用例互不污染。
断言"客户端发了什么"
除了断言 UI,还可以断言请求本身(无需 mock 函数):
ts
import { http, HttpResponse } from "msw";
test("提交表单时发送了正确的载荷", async () => {
let receivedBody: CreateUserInput | null = null;
server.use(
http.post("*/api/users", async ({ request }) => {
receivedBody = (await request.json()) as CreateUserInput;
return HttpResponse.json({ user: { id: "1", ...receivedBody } }, { status: 201 });
}),
);
render(<CreateUserForm />);
await userEvent.type(screen.getByLabelText("姓名"), "小邮差");
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() => expect(receivedBody).toMatchObject({ name: "小邮差", role: "member" }));
});浏览器与 Node 的两个差异(重要)
- 路径必须带通配源:
http.get("*/api/users")在两端都工作;相对路径/api/users在 Node 里不匹配(没有 origin)。 - localStorage 等 Web API:Node 环境不存在。本仓库的假数据库在 Node 下自动退化为内存种子,保证每个测试进程数据确定。
本仓库的测试实践
packages/mocks 直接对 handler 层做单测 —— 启动 server 后用真实 fetch 打每个端点, 覆盖正常/异常/边界。这是"单一真相源"架构的额外红利:handler 自身有测试,应用复用的就是被测试过的契约。
bash
pnpm --filter @msw-tutorial/mocks test # 41 个用例
pnpm --filter @msw-tutorial/mocks test:coverage # 覆盖率(行覆盖 90%+)