MSW (Mock Service Worker) Best Practices Guide (2.x)
版本資訊
- 最新穩定版:MSW 2.12.14
- 需要 Node.js >= 18
- MSW 2.x 原生支援 Fetch API,不需要額外 adapter
npm install -D msw
Vitest 整合設定
建立 handlers
// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("https://api.github.com/users/:username", ({ params }) => {
return HttpResponse.json({
login: params.username,
name: "Test User",
followers: 100,
});
}),
];
建立 server
// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Setup file
// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "./src/mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Vitest config
// vitest.config.ts
test: {
setupFiles: ["./vitest.setup.ts"],
}
三個 lifecycle hook 缺一不可:
server.listen()— 啟用攔截server.resetHandlers()— 移除server.use()的覆蓋server.close()— 還原原始網路行為
Handler 模式
基本 CRUD
import { http, HttpResponse } from "msw";
// GET + path params
http.get("/users/:id", ({ params }) => {
return HttpResponse.json({ id: params.id, name: "Alice" });
});
// POST + request body
http.post("/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 1, ...body }, { status: 201 });
});
// DELETE
http.delete("/users/:id", () => {
return new HttpResponse(null, { status: 204 });
});
// Catch-all
http.all("/analytics/*", () => new HttpResponse(null, { status: 200 }));
Query Params
不要放在 handler URL 裡,從 request.url 讀取:
http.get("https://api.github.com/search/users", ({ request }) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const page = Number(url.searchParams.get("page") || "1");
return HttpResponse.json({ total_count: 1, items: [{ login: "alice" }] });
});
TypeScript 型別
http.post<
{ owner: string; repo: string }, // path params
{ title: string }, // request body
{ id: number; number: number } // response body
>(
"https://api.github.com/repos/:owner/:repo/issues",
async ({ params, request }) => {
const body = await request.json();
return HttpResponse.json({ id: 1, number: 42 });
}
);
HttpResponse 方法
HttpResponse.json({ key: "value" }) // application/json
HttpResponse.json({ error: "Not found" }, { status: 404 })
HttpResponse.text("Hello") // text/plain
HttpResponse.html("<p>Hello</p>") // text/html
HttpResponse.xml("<root><id>1</id></root>") // application/xml
HttpResponse.error() // 網路錯誤(連線失敗)
new HttpResponse(null, { status: 204 }) // 自訂 status
測試覆蓋(server.use)
單一測試的覆蓋,afterEach 的 resetHandlers() 會自動移除:
it("handles server error", async () => {
server.use(
http.get("/api/data", () => {
return HttpResponse.json({ error: "Internal" }, { status: 500 });
})
);
// ... 測試錯誤路徑
});
it("handles network failure", async () => {
server.use(
http.get("/api/data", () => HttpResponse.error())
);
});
連續不同回應
server.use(
http.get("/api/data", () => HttpResponse.json({ first: true }), { once: true }),
http.get("/api/data", () => HttpResponse.json({ second: true })),
);
錯誤模擬
// HTTP 錯誤
http.get("/api", () => HttpResponse.json({ message: "Forbidden" }, { status: 403 }));
// 網路錯誤
http.get("/api", () => HttpResponse.error());
// Timeout
import { delay } from "msw";
http.get("/api", async () => {
await delay(30_000);
return HttpResponse.json({ data: "late" });
});
// Rate limiting
http.get("/api", () => {
return HttpResponse.json(
{ message: "Rate limit exceeded" },
{ status: 429, headers: { "Retry-After": "60" } }
);
});
測試 Octokit
Octokit 發 HTTP 到 https://api.github.com,MSW 直接攔截:
import { http, HttpResponse } from "msw";
export const githubHandlers = [
// Search users
http.get("https://api.github.com/search/users", ({ request }) => {
const url = new URL(request.url);
return HttpResponse.json({
total_count: 1,
incomplete_results: false,
items: [{ login: "alice", id: 1, avatar_url: "https://..." }],
});
}),
// Get user profile
http.get("https://api.github.com/users/:username", ({ params }) => {
return HttpResponse.json({
login: params.username,
name: "Alice",
followers: 100,
company: "ACME",
location: "Taipei",
});
}),
// GraphQL (contribution data)
http.post("https://api.github.com/graphql", async ({ request }) => {
return HttpResponse.json({
data: {
user: {
contributionsCollection: {
contributionCalendar: { totalContributions: 365 },
},
},
},
});
}),
// Rate limit
http.get("https://api.github.com/rate_limit", () => {
return HttpResponse.json({
rate: { remaining: 4999, reset: Math.floor(Date.now() / 1000) + 3600 },
});
}),
];
// test
import { Octokit } from "@octokit/rest";
const octokit = new Octokit({ auth: "fake-token" });
it("searches users", async () => {
const { data } = await octokit.rest.search.users({ q: "location:Taiwan" });
expect(data.items).toHaveLength(1);
expect(data.items[0].login).toBe("alice");
});
msw-fetch-mock
msw-fetch-mock 提供 Undici 風格的 chainable fetch mock API,建構在 MSW 之上。
何時使用:
- 偏好 fluent/chainable 語法
- 從
undici.MockAgent遷移 - 需要內建呼叫歷史追蹤(
fetchMock.calls、lastCall())
MSW 2.x 原生已足夠,msw-fetch-mock 是可選的便利工具。
Handler 組織
src/mocks/
handlers/
github.ts # GitHub API handlers
auth.ts # Auth handlers
index.ts # Re-exports all handlers
server.ts # setupServer(...allHandlers)
// src/mocks/handlers/index.ts
import { githubHandlers } from "./github";
export const handlers = [...githubHandlers];
原則:base handlers 覆蓋 happy path,server.use() 只用於測試特定覆蓋(錯誤、edge case)。
常見陷阱
- 忘記
resetHandlers()—server.use()覆蓋洩漏到後續測試 - Query params 放在 handler URL — 用
new URL(request.url).searchParams - 未 await
request.json()— body 方法都是 async - Node.js 用相對 URL — 必須用絕對 URL
- 從
mswimportsetupServer— 必須從msw/nodeimport - 未設
onUnhandledRequest: "error"— 預設靜默通過,設為 error 及早發現問題 - 全域和單檔都呼叫
server.listen()— 只在 setup file 呼叫一次 - 連續呼叫不同回應忘記
{ once: true }— 需要堆疊 handler