MSW — Network Mocking the Right Way
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).
What’s wrong with vi.mock("@/api/users")?
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.
How does MSW work?
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.
Setup in Vitest.
// 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 })),
];The server
// 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" }));
// restore default handlers
afterEach(() => server.resetHandlers());
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.
Per-test override.
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.
How do you simulate network errors (no response)?
import { HttpResponse, http } from "msw";
server.use(
// network error
http.get("/api/users", () => HttpResponse.error()),
);HttpResponse.error() triggers a TypeError on the client side (same as a real network failure). Useful for testing error UI and retry logic.
How do you simulate slow responses?
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.
How do you assert what the client sent?
Read the request inside the handler:
let lastRequest: Request | null = null;
server.use(
http.post("/api/orders", async ({ request }) => {
// clone so the original can still be read
lastRequest = request.clone();
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 }));How does MSW handle GraphQL?
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.
Sharing handlers between tests, Storybook, and dev.
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.
When don’t you use MSW?
- 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
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.
Related
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
Interview angle 5
- “Why MSW rather than mocking the API module?” - because it intercepts at
the network layer, so your real
fetchcall, response parsing, status handling and error mapping all run.vi.mockreplaces exactly the code most likely to contain the bug, and ties the test to the module structure so a refactor breaks it. - “What is the reuse benefit?” - one set of handlers serves the test runner, Storybook and local development against a service worker. The mock data has a single source of truth instead of three that drift.
- “How do you keep tests isolated?” - default handlers for the happy path,
server.use()inside a test for a case-specific override, andserver.resetHandlers()inafterEach. Without the reset, one test’s error case leaks into the next test’s happy path. - “How do you catch a request you forgot to handle?” -
onUnhandledRequest: "error". The default is a warning, and a quiet unhandled request usually surfaces as an empty state the test happens to tolerate. - “How do you test loading and failure states?” -
delay("infinite")to hold the request open so the loading UI is assertable, andHttpResponse.error()for a network-level failure as distinct from a 500. Those are different code paths and worth testing separately.