Skip to main content
Technology & EngineeringTesting Services445 lines

MSW

"MSW (Mock Service Worker): API mocking, request handlers, rest/graphql, browser/node, setupServer, network-level interception"

Quick Summary18 lines
Mock Service Worker (MSW) intercepts HTTP requests at the network level rather than patching `fetch` or `XMLHttpRequest`. This means your application code, including any HTTP client (Axios, fetch wrappers, GraphQL clients), runs exactly as it would in production. The only difference is that requests never reach the real server; MSW's service worker (in the browser) or request interceptor (in Node.js) responds with handlers you define. This approach ensures your mocks stay realistic, your tests exercise the full client-side stack, and you can reuse the same handlers across unit tests, integration tests, Storybook stories, and local development.

## Key Points

- Define base handlers in a shared `handlers.ts` file and override per-test with `server.use()`; overrides are reset by `server.resetHandlers()` in `afterEach`.
- Set `onUnhandledRequest: "error"` in tests to immediately catch requests that lack handlers, preventing silent network failures.
- Use `onUnhandledRequest: "bypass"` in the browser worker to allow requests to real assets (fonts, images) during development.
- Build handler factory functions (like `withEmptyUsers`) for common scenarios to keep tests declarative and reduce duplication.
- Share the same handlers between tests, Storybook, and local development to maintain a single source of truth for mock data.
- Use `delay()` from MSW to test loading states; use `HttpResponse.error()` to test network failure handling.
- Type request bodies and params using TypeScript generics to catch payload mismatches at compile time.
- Do not mock `fetch` or `axios` directly (e.g., `vi.mock("axios")`); MSW intercepts at the network level, so the full client stack is exercised.
- Do not forget `server.resetHandlers()` in `afterEach`; without it, per-test overrides leak into subsequent tests.
- Do not use `server.use()` at the module level outside of test lifecycle hooks; it must run after `server.listen()`.
- Avoid hardcoding absolute URLs in handlers when your app uses relative paths; match the same paths your app actually requests.
- Do not create one giant handler with complex branching logic; compose small, focused handlers and override per scenario.
skilldb get testing-services-skills/MSWFull skill: 445 lines
Paste into your CLAUDE.md or agent config

MSW (Mock Service Worker)

Core Philosophy

Mock Service Worker (MSW) intercepts HTTP requests at the network level rather than patching fetch or XMLHttpRequest. This means your application code, including any HTTP client (Axios, fetch wrappers, GraphQL clients), runs exactly as it would in production. The only difference is that requests never reach the real server; MSW's service worker (in the browser) or request interceptor (in Node.js) responds with handlers you define. This approach ensures your mocks stay realistic, your tests exercise the full client-side stack, and you can reuse the same handlers across unit tests, integration tests, Storybook stories, and local development.

Setup

Installation and Configuration

// npm install -D msw

// Initialize the service worker for browser usage
// npx msw init ./public --save

// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";

export interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "viewer";
}

const users: User[] = [
  { id: "1", name: "Alice", email: "alice@test.com", role: "admin" },
  { id: "2", name: "Bob", email: "bob@test.com", role: "viewer" },
];

export const handlers = [
  // GET collection
  http.get("/api/users", ({ request }) => {
    const url = new URL(request.url);
    const role = url.searchParams.get("role");

    const filtered = role ? users.filter((u) => u.role === role) : users;
    return HttpResponse.json(filtered);
  }),

  // GET single resource
  http.get("/api/users/:id", ({ params }) => {
    const user = users.find((u) => u.id === params.id);
    if (!user) {
      return HttpResponse.json(
        { error: "User not found" },
        { status: 404 }
      );
    }
    return HttpResponse.json(user);
  }),

  // POST create
  http.post("/api/users", async ({ request }) => {
    const body = (await request.json()) as Omit<User, "id">;
    const newUser: User = { ...body, id: crypto.randomUUID() };
    return HttpResponse.json(newUser, { status: 201 });
  }),

  // PATCH update
  http.patch("/api/users/:id", async ({ params, request }) => {
    const updates = (await request.json()) as Partial<User>;
    const user = users.find((u) => u.id === params.id);
    if (!user) {
      return HttpResponse.json({ error: "Not found" }, { status: 404 });
    }
    const updated = { ...user, ...updates };
    return HttpResponse.json(updated);
  }),

  // DELETE
  http.delete("/api/users/:id", ({ params }) => {
    return new HttpResponse(null, { status: 204 });
  }),
];

Node.js Server for Tests

// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "../mocks/server";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Browser Worker for Development and Storybook

// src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

// src/main.tsx (conditional startup)
async function enableMocking() {
  if (process.env.NODE_ENV !== "development") return;

  const { worker } = await import("./mocks/browser");
  return worker.start({
    onUnhandledRequest: "bypass",
  });
}

enableMocking().then(() => {
  createRoot(document.getElementById("root")!).render(<App />);
});

Key Techniques

Testing React Components with MSW

// src/components/UserList.test.tsx
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
import { UserList } from "./UserList";

describe("UserList", () => {
  it("renders users fetched from the API", async () => {
    render(<UserList />);

    // Component fetches /api/users on mount; default handlers respond
    expect(await screen.findByText("Alice")).toBeVisible();
    expect(screen.getByText("Bob")).toBeVisible();
  });

  it("displays error message on server failure", async () => {
    // Override handler for this single test
    server.use(
      http.get("/api/users", () => {
        return HttpResponse.json(
          { error: "Database unavailable" },
          { status: 500 }
        );
      })
    );

    render(<UserList />);

    expect(await screen.findByRole("alert")).toHaveTextContent(
      "Failed to load users"
    );
  });

  it("filters users by role", async () => {
    const user = userEvent.setup();
    render(<UserList />);

    // Wait for initial load
    await screen.findByText("Alice");

    // Select role filter
    await user.selectOptions(
      screen.getByRole("combobox", { name: "Filter by role" }),
      "admin"
    );

    // Only admin users should remain
    expect(screen.getByText("Alice")).toBeVisible();
    expect(screen.queryByText("Bob")).not.toBeInTheDocument();
  });
});

Request Assertions and Spying

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
import { CreateUserForm } from "./CreateUserForm";

describe("CreateUserForm", () => {
  it("sends correct payload when creating a user", async () => {
    let capturedBody: unknown;

    server.use(
      http.post("/api/users", async ({ request }) => {
        capturedBody = await request.json();
        return HttpResponse.json(
          { id: "new-1", ...capturedBody as object },
          { status: 201 }
        );
      })
    );

    const user = userEvent.setup();
    render(<CreateUserForm />);

    await user.type(screen.getByLabelText("Name"), "Charlie");
    await user.type(screen.getByLabelText("Email"), "charlie@test.com");
    await user.selectOptions(screen.getByLabelText("Role"), "viewer");
    await user.click(screen.getByRole("button", { name: "Create" }));

    // Verify the request payload
    expect(capturedBody).toEqual({
      name: "Charlie",
      email: "charlie@test.com",
      role: "viewer",
    });

    // Verify success feedback
    expect(await screen.findByText("User created successfully")).toBeVisible();
  });
});

GraphQL Handlers

// src/mocks/graphql-handlers.ts
import { graphql, HttpResponse } from "msw";

export const graphqlHandlers = [
  // Query
  graphql.query("GetUser", ({ variables }) => {
    const { id } = variables;
    return HttpResponse.json({
      data: {
        user: {
          id,
          name: "Alice",
          email: "alice@test.com",
          posts: [
            { id: "p1", title: "First Post" },
            { id: "p2", title: "Second Post" },
          ],
        },
      },
    });
  }),

  // Mutation
  graphql.mutation("CreatePost", ({ variables }) => {
    return HttpResponse.json({
      data: {
        createPost: {
          id: "p-new",
          title: variables.title,
          createdAt: new Date().toISOString(),
        },
      },
    });
  }),

  // Simulate GraphQL error
  graphql.query("GetDashboard", () => {
    return HttpResponse.json({
      errors: [
        {
          message: "Not authorized",
          extensions: { code: "FORBIDDEN" },
        },
      ],
    });
  }),
];

Simulating Network Conditions

import { http, HttpResponse, delay } from "msw";

export const networkHandlers = [
  // Simulate slow response
  http.get("/api/reports", async () => {
    await delay(3000);
    return HttpResponse.json({ data: [] });
  }),

  // Simulate network error
  http.get("/api/health", () => {
    return HttpResponse.error();
  }),

  // Simulate streaming / progressive response
  http.get("/api/stream", () => {
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("chunk-1\n"));
        controller.enqueue(new TextEncoder().encode("chunk-2\n"));
        controller.close();
      },
    });

    return new HttpResponse(stream, {
      headers: { "Content-Type": "text/plain" },
    });
  }),
];

Dynamic Handler Composition

// Factory functions for reusable test scenarios
function withEmptyUsers() {
  return http.get("/api/users", () => {
    return HttpResponse.json([]);
  });
}

function withPaginatedUsers(page: number, totalPages: number) {
  return http.get("/api/users", ({ request }) => {
    const url = new URL(request.url);
    const requestedPage = Number(url.searchParams.get("page") ?? "1");

    return HttpResponse.json({
      users: [{ id: `user-${requestedPage}`, name: `User ${requestedPage}` }],
      meta: {
        page: requestedPage,
        totalPages,
        hasNext: requestedPage < totalPages,
      },
    });
  });
}

function withAuthError() {
  return http.get("/api/users", () => {
    return HttpResponse.json(
      { error: "Token expired" },
      { status: 401 }
    );
  });
}

// Usage in tests
describe("UserList edge cases", () => {
  it("shows empty state", async () => {
    server.use(withEmptyUsers());
    render(<UserList />);
    expect(await screen.findByText("No users found")).toBeVisible();
  });

  it("handles auth expiration", async () => {
    server.use(withAuthError());
    render(<UserList />);
    expect(await screen.findByText("Session expired")).toBeVisible();
  });

  it("supports pagination", async () => {
    server.use(withPaginatedUsers(1, 5));
    render(<UserList />);
    expect(await screen.findByText("Page 1 of 5")).toBeVisible();
  });
});

Storybook Integration

// src/components/UserList.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { http, HttpResponse } from "msw";
import { UserList } from "./UserList";

const meta: Meta<typeof UserList> = {
  component: UserList,
};
export default meta;

type Story = StoryObj<typeof UserList>;

export const Default: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get("/api/users", () =>
          HttpResponse.json([
            { id: "1", name: "Alice", role: "admin" },
            { id: "2", name: "Bob", role: "viewer" },
          ])
        ),
      ],
    },
  },
};

export const Empty: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get("/api/users", () => HttpResponse.json([])),
      ],
    },
  },
};

export const Error: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get("/api/users", () =>
          HttpResponse.json({ error: "Server error" }, { status: 500 })
        ),
      ],
    },
  },
};

Best Practices

  • Define base handlers in a shared handlers.ts file and override per-test with server.use(); overrides are reset by server.resetHandlers() in afterEach.
  • Set onUnhandledRequest: "error" in tests to immediately catch requests that lack handlers, preventing silent network failures.
  • Use onUnhandledRequest: "bypass" in the browser worker to allow requests to real assets (fonts, images) during development.
  • Build handler factory functions (like withEmptyUsers) for common scenarios to keep tests declarative and reduce duplication.
  • Share the same handlers between tests, Storybook, and local development to maintain a single source of truth for mock data.
  • Use delay() from MSW to test loading states; use HttpResponse.error() to test network failure handling.
  • Type request bodies and params using TypeScript generics to catch payload mismatches at compile time.

Anti-Patterns

  • Do not mock fetch or axios directly (e.g., vi.mock("axios")); MSW intercepts at the network level, so the full client stack is exercised.
  • Do not forget server.resetHandlers() in afterEach; without it, per-test overrides leak into subsequent tests.
  • Do not use server.use() at the module level outside of test lifecycle hooks; it must run after server.listen().
  • Avoid hardcoding absolute URLs in handlers when your app uses relative paths; match the same paths your app actually requests.
  • Do not create one giant handler with complex branching logic; compose small, focused handlers and override per scenario.
  • Avoid using delay("infinite") in tests without a corresponding timeout strategy; it will cause the test to hang.

Install this skill directly: skilldb add testing-services-skills

Get CLI access →