MSW — Network Mocking the Right Way
TL;DR
Mock Service Worker (MSW) intercepts HTTP at the network layer — your code’s real fetch/axios calls go through their normal path, MSW returns mocked responses. Replaces vi.mock("./api") / jest.mock for API mocking. Why it’s better: tests exercise your real fetch/parsing/error-handling code, not the boundary you mocked. Same handlers run in unit tests (Node), Storybook (browser), and dev (browser via service worker).
Interview Q&A
Q: What’s wrong with vi.mock("@/api/users")?
A: Three problems:
- Ties tests to implementation — refactor
api/users.ts(rename functions, split files, switch to a generated client), and every test using the mock breaks. - Skips real fetch/parsing/error code — the test passes through your mocked function and never exercises
JSON.parse, status-code handling, retry logic, etc. - Per-module mocks don’t compose — mocking multiple modules creates a mess of
vi.mockcalls. MSW handlers are declarative + composable.
Q: How does MSW work?
A: Intercepts at the network layer. In Node (tests), it uses request interception via undici/node-fetch. In the browser (dev/Storybook), it uses a service worker that intercepts before the request leaves the page.
Your code:
const res = await fetch("/api/users");
MSW catches the request before it goes to the network, runs your handler, returns the response. Your code is none the wiser — it sees a real Response object.
Q: Setup in Vitest.
A:
// test/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users", () => HttpResponse.json([{ id: 1, name: "Ada" }])),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({ id: Number(params.id), name: "Ada" });
}),
http.post("/api/orders", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "ord_1", ...body }, { status: 201 });
}),
http.delete("/api/users/:id", () => new HttpResponse(null, { status: 204 })),
];
// test/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// test/setup.ts
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "./server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers()); // restore default handlers
afterAll(() => server.close());
// vite.config.ts
export default defineConfig({
test: {
setupFiles: "./test/setup.ts",
environment: "happy-dom",
},
});
That’s it. Every test file now has fake responses for those endpoints. Real fetch runs.
Q: Per-test override.
A: server.use() adds handlers for the current test (reset by afterEach):
it("shows error on 500", async () => {
server.use(
http.get("/api/users", () => new HttpResponse(null, { status: 500 })),
);
render(<UserList />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
it("shows empty state on []", async () => {
server.use(
http.get("/api/users", () => HttpResponse.json([])),
);
render(<UserList />);
expect(await screen.findByText(/no users yet/i)).toBeInTheDocument();
});
server.resetHandlers() in afterEach restores defaults. Each test customizes only what’s relevant.
Q: How do you simulate network errors (no response)?
A:
import { HttpResponse, http } from "msw";
server.use(
http.get("/api/users", () => HttpResponse.error()), // network error
);
HttpResponse.error() triggers a TypeError on the client side (same as a real network failure). Useful for testing error UI and retry logic.
Q: How do you simulate slow responses?
A: delay:
import { delay, http, HttpResponse } from "msw";
server.use(
http.get("/api/users", async () => {
await delay(500); // 500ms
return HttpResponse.json([]);
}),
// or delay("infinite") to never respond — tests loading states
http.get("/api/orders", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
);
delay("infinite") is the cleanest way to test “loading spinner shows while request is pending” — the spinner stays on screen, you assert it.
Q: How do you assert what the client sent?
A: Read the request inside the handler:
let lastRequest: Request | null = null;
server.use(
http.post("/api/orders", async ({ request }) => {
lastRequest = request.clone(); // clone so the original can still be read
return HttpResponse.json({ ok: true });
}),
);
// in test
await userEvent.click(screen.getByRole("button", { name: /submit/i }));
expect(lastRequest).not.toBeNull();
const body = await lastRequest!.json();
expect(body).toEqual({ total: 100 });
For simpler cases, use vi.fn()-style spy that calls the original handler:
const onSubmit = vi.fn();
server.use(
http.post("/api/orders", async ({ request }) => {
onSubmit(await request.json());
return HttpResponse.json({ ok: true });
}),
);
// assert
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ total: 100 }));
Q: How does MSW handle GraphQL?
A: Built-in. Use graphql.query/graphql.mutation handlers:
import { graphql, HttpResponse } from "msw";
server.use(
graphql.query("GetUser", ({ variables }) => {
return HttpResponse.json({
data: { user: { id: variables.id, name: "Ada" } },
});
}),
graphql.mutation("CreateOrder", () => {
return HttpResponse.json({ data: { createOrder: { id: "ord_1" } } });
}),
);
Handles named operations. For all-purpose interception, graphql.operation.
Q: Sharing handlers between tests, Storybook, and dev.
A: The “real” win. Define handlers once; use them everywhere:
// test/server.ts (Node, for tests)
import { setupServer } from "msw/node";
import { handlers } from "../mocks/handlers";
export const server = setupServer(...handlers);
// mocks/browser.ts (browser, for dev/Storybook)
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
// main.tsx — start in dev mode
if (import.meta.env.DEV) {
const { worker } = await import("./mocks/browser");
await worker.start();
}
In Storybook, register MSW addon and your stories serve from the same handlers your tests use. One source of truth for mock data.
Q: When don’t you use MSW?
A:
- Backend-less unit tests — when you’re testing a pure function that doesn’t
fetch, MSW is irrelevant. - Testing the API client library itself — when the SUT is the fetch wrapper, you want to exercise it against a real local server or
vi.mock("undici")to control at the lowest level. - Existing codebases with deep
jest.mockof API modules — migration may not be worth it.
For component/integration tests that talk to APIs, MSW is the right answer.
Gotchas / edge cases
onUnhandledRequest: "error"is recommended — fails the test if your code hits an unmocked endpoint. Catches “I forgot to add a handler” bugs early.server.resetHandlers()restores the default list — without it, a per-test handler leaks to subsequent tests.- MSW intercepts cross-origin too — useful for testing third-party calls (Stripe, analytics) without hitting the real service.
- Service worker in browser dev requires the
mockServiceWorker.jsfile in/public; MSW provides a CLI to generate it. - Cookies / credentials — MSW receives the request with cookies; you can inspect or mock them in responses (
Set-Cookieheader). - Streaming responses — MSW supports
ReadableStreamresponses for SSE testing. - Type-safe handlers — handlers are inherently untyped (paths are strings). Pair with OpenAPI/TS codegen or a typed wrapper for safer tests.
What a senior is expected to say
- “MSW intercepts at the network — my real fetch/parsing/error code runs.
vi.mockof API modules ties tests to implementation and skips the network layer.” - “One handler file used by tests (
msw/node), Storybook (msw/browser), and dev (browser via service worker). Single source of truth for mock data.” - “Per-test
server.use()for overrides;afterEach(() => server.resetHandlers())to keep tests isolated.” - “
onUnhandledRequest: 'error'catches forgotten handlers — a quiet404would otherwise let bugs through.” - “
delay('infinite')is how I test loading states;HttpResponse.error()for network failures.”
Cross-references
- Vitest setup: 02_vitest_vs_jest.md
- Async UI testing (uses MSW): 06_async_ui_testing.md
- TanStack Query testing (often paired with MSW): ../11_apis_data_fetching/02_tanstack_query.md
Further reading
- MSW docs: https://mswjs.io/
- MSW Storybook addon: https://storybook.js.org/addons/msw-storybook-addon
- MSW + GraphQL: https://mswjs.io/docs/api/graphql