frontend / testing / 05_msw_network_mocking.md

MSW — Network Mocking the Right Way

6 min read source

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:

  1. Ties tests to implementation — refactor api/users.ts (rename functions, split files, switch to a generated client), and every test using the mock breaks.
  2. Skips real fetch/parsing/error code — the test passes through your mocked function and never exercises JSON.parse, status-code handling, retry logic, etc.
  3. Per-module mocks don’t compose — mocking multiple modules creates a mess of vi.mock calls. 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.mock of 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.js file 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-Cookie header).
  • Streaming responses — MSW supports ReadableStream responses 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.mock of 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 quiet 404 would otherwise let bugs through.”
  • delay('infinite') is how I test loading states; HttpResponse.error() for network failures.”

Cross-references

Further reading