Mock Service Worker — Intercepting API Calls in Tests

Component tests that mock axios or fetch directly are testing your mocks, not your actual HTTP stack. If the Axios interceptor adds a header incorrectly, or if RTK Query serialises the request body in an unexpected way, a direct mock of axios.post would never catch it. Mock Service Worker (MSW) intercepts requests at the network level — the actual fetch calls are made, the MSW service worker intercepts them before they leave the browser, and returns mock responses. This means your Axios instance, interceptors, RTK Query serialisation, and all other middleware run exactly as in production.

MSW Setup

npm install -D msw
// src/test/mocks/handlers.js — define request handlers
import { http, HttpResponse } from "msw";

const mockPosts = [
    {
        id: 1, title: "Hello FastAPI", slug: "hello-fastapi",
        body: "Learning FastAPI and React.", status: "published",
        like_count: 3, view_count: 42, created_at: "2025-06-01T12:00:00Z",
        author: { id: 10, name: "Alice", avatar_url: null },
        tags:   [{ id: 1, name: "fastapi" }],
    },
];

export const handlers = [
    // GET /api/posts
    http.get("/api/posts", ({ request }) => {
        const url = new URL(request.url);
        const page = Number(url.searchParams.get("page") ?? 1);
        return HttpResponse.json({
            items:     mockPosts,
            total:     1,
            pages:     1,
            page,
            page_size: 10,
        });
    }),

    // GET /api/posts/:id
    http.get("/api/posts/:id", ({ params }) => {
        const post = mockPosts.find((p) => p.id === Number(params.id));
        if (!post) return HttpResponse.json({ detail: "Not found" }, { status: 404 });
        return HttpResponse.json(post);
    }),

    // POST /api/auth/login
    http.post("/api/auth/login", async ({ request }) => {
        const { email, password } = await request.json();
        if (email === "test@example.com" && password === "Password1") {
            return HttpResponse.json({
                access_token:  "mock-access-token",
                refresh_token: "mock-refresh-token",
                token_type:    "bearer",
            });
        }
        return HttpResponse.json(
            { detail: "Invalid credentials" },
            { status: 401 }
        );
    }),

    // GET /api/users/me
    http.get("/api/users/me", ({ request }) => {
        const auth = request.headers.get("Authorization");
        if (auth !== "Bearer mock-access-token") {
            return HttpResponse.json({ detail: "Not authenticated" }, { status: 401 });
        }
        return HttpResponse.json({
            id: 10, name: "Alice", email: "test@example.com",
            role: "user", avatar_url: null,
        });
    }),
];

// src/test/mocks/server.js — Node.js MSW server for Vitest
import { setupServer } from "msw/node";
import { handlers }    from "./handlers";

export const server = setupServer(...handlers);
Note: MSW v2 uses http.get(), http.post() (etc.) from the msw package and HttpResponse for creating responses — a clean API that replaced the v1 rest.get() pattern. In Node.js test environments (Vitest, Jest), use setupServer from msw/node. For browser environments (Storybook, manual testing in the browser), use setupWorker from msw/browser. The same handlers work in both environments — write once, use everywhere.
Tip: Override handlers in individual tests to test error states and edge cases without changing the global handler: server.use(http.get("/api/posts", () => HttpResponse.json({ detail: "Server error" }, { status: 500 }))). After server.resetHandlers() (called in afterEach), the override is removed and the default handler is restored. This lets you test the loading state, error state, and success state of the same component in three separate tests with three different mock responses.
Warning: MSW intercepts requests matched by URL pattern. If your component makes requests to http://localhost:8000/api/posts (absolute URL) but your handler matches /api/posts (relative path), the handler will not fire and the actual network request will be attempted — usually resulting in a network error in the test environment. Ensure your MSW handler URLs match what your RTK Query or Axios instance actually sends. Using a relative base URL (/api) in the Axios/RTK Query config eliminates this ambiguity.

Testing a Component with API Calls

// src/pages/__tests__/HomePage.test.jsx
import { screen, waitFor }   from "@testing-library/react";
import { renderWithProviders } from "@/test/helpers/renderWithProviders";
import { server }              from "@/test/mocks/server";
import { http, HttpResponse }  from "msw";
import HomePage                from "@/pages/HomePage";

describe("HomePage", () => {
    it("renders the post list after loading", async () => {
        renderWithProviders(<HomePage />);

        // While loading, skeleton should be visible
        expect(screen.getAllByTestId("post-skeleton")).toHaveLength(3);

        // After loading, post titles appear
        await waitFor(() => {
            expect(screen.getByText("Hello FastAPI")).toBeInTheDocument();
        });
    });

    it("shows error state when API fails", async () => {
        // Override handler for this test only
        server.use(
            http.get("/api/posts", () =>
                HttpResponse.json({ detail: "Server error" }, { status: 500 })
            )
        );

        renderWithProviders(<HomePage />);

        await waitFor(() => {
            expect(screen.getByText(/failed to load posts/i)).toBeInTheDocument();
        });
    });

    it("shows empty state when no posts", async () => {
        server.use(
            http.get("/api/posts", () =>
                HttpResponse.json({ items: [], total: 0, pages: 0, page: 1, page_size: 10 })
            )
        );

        renderWithProviders(<HomePage />);

        await waitFor(() => {
            expect(screen.getByText(/no posts here yet/i)).toBeInTheDocument();
        });
    });
});

Common Mistakes

Mistake 1 — Forgetting resetHandlers in afterEach

❌ Wrong — test overrides leak into subsequent tests:

// server.use() in one test changes handlers for all subsequent tests!

✅ Correct — server.resetHandlers() in afterEach restores defaults.

Mistake 2 — Handler URL does not match actual request URL

❌ Wrong — absolute URL in component, relative pattern in handler:

// Component fetches "http://localhost:8000/api/posts"
// Handler matches "/api/posts" — NO MATCH, real request attempted

✅ Correct — ensure your base URL config and handler patterns are consistent.

🧠 Test Yourself

Why is MSW superior to mocking axios directly (e.g., vi.mock("axios")) for component tests?