MSW: API Mocking Without Touching App Code
The hardest part of testing data-driven React components is not rendering—it is deciding where to
lie. Lie too close to the component under test, and every test repeats bespoke stubs of your HTTP
client. Lie too far away, and you are running brittle tests against shared staging services. Mock
Service Worker (MSW) takes a different approach: it intercepts HTTP requests at the network
boundary, so your application code keeps calling fetch, Axios, or TanStack Query exactly the way
it does in production. Only the transport is simulated.
That matters for TypeScript-heavy codebases. Your types often describe what you believe the server returns. MSW lets you enforce that contract in tests by returning JSON that satisfies those types while still exercising parsing, error handling, and loading transitions in the real call path.
Handlers as a shared contract
Define handlers once, with TypeScript generics where they help clarify payloads:
import { http, HttpResponse } from "msw";
interface Post {
id: string;
title: string;
body: string;
}
export const handlers = [
http.get("/api/posts", () => {
return HttpResponse.json<Post[]>([
{ id: "1", title: "First Post", body: "Content..." },
{ id: "2", title: "Second Post", body: "More content..." },
]);
}),
http.post("/api/posts", async ({ request }) => {
const body = (await request.json()) as Partial<Post>;
return HttpResponse.json<Post>(
{
id: String(Date.now()),
title: body.title ?? "",
body: body.body ?? "",
},
{ status: 201 },
);
}),
http.get("/api/posts/:id", ({ params }) => {
if (params.id === "404") {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json<Post>({ id: params.id as string, title: "Post", body: "Content" });
}),
];
Handlers are plain data describing routes and responses. They are easy to read in review and easy to reuse outside tests.
Running MSW in Node for Vitest
For unit and integration tests, MSW’s Node integration spins up a request interception layer without a browser service worker:
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Wire the server into your test lifecycle so each file starts from a clean slate:
import { server } from "../mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
onUnhandledRequest: 'error' is an opinionated default that pays off in large teams: if the app
starts calling a new endpoint and no handler exists, the test fails loudly instead of silently
hitting the real network or returning ambiguous failures. During early prototyping you might
temporarily downgrade that behavior, but for mature suites strictness prevents drift.
Per-test overrides without touching app code
The compelling workflow is server.use to layer scenario-specific behavior on top of defaults.
The production component code stays untouched; only the network story changes.
it('shows error when API fails', async () => {
server.use(http.get('/api/posts', () => new HttpResponse(null, { status: 500 })))
render(<PostsList />)
expect(await screen.findByText(/failed to load posts/i)).toBeInTheDocument()
})
This pattern scales to pagination edge cases, slow responses, malformed JSON, and auth failures—each
test names a scenario explicitly. After each test, resetHandlers() restores the baseline so
order-dependent bugs do not creep in.
Browser versus Node: one handler library, two runtimes
MSW became popular partly because the same handlers can run in the browser during local
development—via a service worker—and in Node during automated tests. That alignment reduces the
classic problem where “works in test with mocked apiClient” does not match how the browser
actually issues requests. You still need judgment: some environments require absolute URLs or
careful base path configuration, and service worker registration is a development workflow concern,
not something every test file should duplicate.
Compared to manual module mocks
Mocking fetch inline or replacing a module with vi.mock is fast to write once and expensive to
maintain across dozens of files. It also tends to overfit to call signatures—your test asserts that
a particular function was invoked with particular arguments, which may have little to do with
user-visible outcomes. MSW keeps the HTTP semantics—status codes, headers, bodies,
streaming—centralized. When the client library changes but the wire format does not, handlers often
keep working unchanged.
The trade-off is setup complexity and discipline about unhandled requests. Teams that invest in that
upfront typically recover the cost the first time a refactor would have required touching twenty
vi.mock blocks.
Request matching, delays, and absolute URLs
Handlers match on method and path, and you can tighten behavior with predicates—headers, query strings, or parsed bodies—when scenarios need to diverge. To exercise races (“slow list arrives after fast detail”), MSW can delay responses so you assert loading UI without real wall-clock sleeps. GraphQL clients fit the same pattern: key handlers on operation name or shape and return JSON aligned with schema-derived types so the client and mock evolve together.
If the app calls absolute URLs—common with environment-based API hosts—mirror that in handlers
or centralize a getBaseUrl() shared by app and tests. Few things erode confidence faster than
tests intercepting one origin while the browser would call another.
Handlers as living API documentation
Because handlers are plain objects, they are easy to export from a mocks package in a monorepo and
consume from Storybook, Playwright, and Vitest alike. That reuse is where MSW stops
being “a test utility” and becomes how the team agrees on what the backend means before it exists.
Shared TypeScript interfaces between handlers and components turn contract changes into compile-time
work instead of mysterious 500s in QA.
Loading, success, and failure as first-class stories
Advanced React applications spend a surprising fraction of their UI code on states users see only transiently: skeletons, spinners, inline errors, retry buttons. MSW makes it straightforward to test those branches because you control timing and status codes without rewriting components to accept “fake data props” solely for tests. That keeps production APIs honest: you are not adding test-only props that would never exist in the real tree.
When combined with Vitest and React Testing Library, MSW completes a loop: fast tests, realistic I/O, and user-facing assertions—without entangling your application modules in a web of ad hoc doubles.