Type something to search...
Mocking API Calls in React Tests with MSW

Mocking API Calls in React Tests with MSW

Sooner or later, every React component test runs into the network. The component calls fetch in an effect, or uses TanStack Query, or goes through an Axios instance with interceptors. The usual quick fix is to mock whatever does the request: vi.mock("axios"), or vi.spyOn(globalThis, "fetch"). It works until you switch from Axios to fetch, or add a query library, and every test breaks even though the app still works.

Mock Service Worker (MSW) takes a different approach. Instead of replacing your HTTP client, it intercepts requests at the network level and answers them with responses you define. Your components, hooks, and data libraries run exactly as they do in production. Only the server is fake. The same handlers work in Vitest, in the browser during development, and in tools like Storybook.

This post sets up MSW 2 with Vitest and React Testing Library, then covers realistic scenarios: shared handlers, per-test overrides, error and loading states, inspecting request bodies, query parameters, GraphQL, and using the same mocks in the browser.

How MSW Works

You describe your API as a list of request handlers. Each handler matches a method and URL, and returns a response:

import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("https://api.example.com/users/:id", ({ params }) => {
    return HttpResponse.json({ id: params.id, name: "Ada Lovelace" });
  }),
];

Then you pass the handlers to an integration:

  • In Node (Vitest, Jest), setupServer from msw/node patches Node's request modules and the global fetch, so outgoing requests are matched against your handlers.
  • In the browser, setupWorker from msw/browser registers a real Service Worker that intercepts requests the page makes. You can see them in the Network tab.

Requests that don't match any handler can either pass through to the real network or fail loudly. In tests, failing loudly is what you want.

Installing and Setting Up MSW for Vitest

Install MSW as a dev dependency:

npm install -D msw

This post assumes Vitest and Testing Library are already configured. If not, start with testing React components with Vitest and Testing Library.

Organize mocks in their own folder:

src/mocks/
  handlers.ts   # shared request handlers
  data.ts       # fixture data
  node.ts       # server for Vitest
  browser.ts    # worker for the browser

Fixture Data and Handlers

Keep fixture data separate so tests can import it for assertions:

// src/mocks/data.ts
export type Todo = { id: number; title: string; completed: boolean };

export const todos: Todo[] = [
  { id: 1, title: "Write tests", completed: false },
  { id: 2, title: "Ship feature", completed: true },
];
// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
import { todos, type Todo } from "./data";

export const API = "https://api.example.com";

export const handlers = [
  http.get(`${API}/todos`, () => {
    return HttpResponse.json(todos);
  }),

  http.post(`${API}/todos`, async ({ request }) => {
    const body = (await request.json()) as Pick<Todo, "title">;
    const created: Todo = { id: Date.now(), title: body.title, completed: false };
    return HttpResponse.json(created, { status: 201 });
  }),

  http.delete(`${API}/todos/:id`, () => {
    return new HttpResponse(null, { status: 204 });
  }),
];

The handlers read the request with standard Fetch API methods like request.json(), and build responses with HttpResponse, which extends the native Response. That's one of the main changes in MSW 2: everything uses web standards, not custom req, res, and ctx objects.

This example uses absolute URLs. In Node, fetch can't resolve a relative URL like /todos because there's no page origin, so give your app a configurable base URL (from an environment variable, for example) and use the same value in handlers.

The Test Server

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

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

Wire its lifecycle into your Vitest setup file:

// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "../mocks/node";

beforeAll(() => {
  server.listen({ onUnhandledRequest: "error" });
});

afterEach(() => {
  cleanup();
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});

Each line has a job:

  • server.listen starts intercepting. With onUnhandledRequest: "error", any request without a handler throws, so you never accidentally hit a real API.
  • server.resetHandlers removes overrides added during a test, so one test's error scenario doesn't leak into the next.
  • server.close restores the original network modules at the end.

Testing a Component That Fetches Data

Here's a todo list built with TanStack Query:

// src/features/todos/TodoList.tsx
import { useQuery } from "@tanstack/react-query";
import type { Todo } from "../../mocks/data";

const API = import.meta.env.VITE_API_URL ?? "https://api.example.com";

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch(`${API}/todos`);
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  return res.json();
}

export function TodoList() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  });

  if (isPending) return <p>Loading todos…</p>;
  if (isError) return <p role="alert">Could not load todos: {error.message}</p>;
  if (data.length === 0) return <p>No todos yet.</p>;

  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>
          <label>
            <input type="checkbox" checked={todo.completed} readOnly />
            {todo.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

In a real app, the Todo type would live in your own types module rather than the mocks folder. And a render helper that provides a fresh QueryClient per test, with retries off:

// src/test/render.tsx
import type { ReactElement, ReactNode } from "react";
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export function renderWithClient(ui: ReactElement) {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
  }
  return render(ui, { wrapper: Wrapper });
}

Now the happy-path test doesn't mention the network at all:

// src/features/todos/TodoList.test.tsx
import { screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { renderWithClient } from "../../test/render";
import { TodoList } from "./TodoList";

test("renders todos from the API", async () => {
  renderWithClient(<TodoList />);

  expect(screen.getByText("Loading todos…")).toBeInTheDocument();
  expect(await screen.findByText("Write tests")).toBeInTheDocument();
  expect(screen.getByRole("checkbox", { name: "Ship feature" })).toBeChecked();
});

The component makes a real fetch call, TanStack Query caches the result, and MSW answers from the shared handler. If you later replace fetch with Axios or ky, this test keeps passing.

Overriding Handlers Per Test

Shared handlers describe the happy path. For empty states, errors, and edge cases, override a handler inside a single test with server.use. Overrides are prepended, so they win over the defaults until resetHandlers runs:

// src/features/todos/TodoList.test.tsx (continued)
import { screen } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { expect, test } from "vitest";
import { server } from "../../mocks/node";
import { API } from "../../mocks/handlers";
import { renderWithClient } from "../../test/render";
import { TodoList } from "./TodoList";

test("shows an empty state", async () => {
  server.use(http.get(`${API}/todos`, () => HttpResponse.json([])));

  renderWithClient(<TodoList />);
  expect(await screen.findByText("No todos yet.")).toBeInTheDocument();
});

test("shows an error when the server fails", async () => {
  server.use(
    http.get(`${API}/todos`, () => {
      return HttpResponse.json({ message: "Internal error" }, { status: 500 });
    }),
  );

  renderWithClient(<TodoList />);
  expect(await screen.findByRole("alert")).toHaveTextContent("Request failed: 500");
});

test("shows an error when the network is down", async () => {
  server.use(http.get(`${API}/todos`, () => HttpResponse.error()));

  renderWithClient(<TodoList />);
  expect(await screen.findByRole("alert")).toBeInTheDocument();
});

Note the difference between the last two tests. A 500 response is still a successful HTTP round trip, so fetch resolves and your code must check res.ok. HttpResponse.error() simulates a network failure, so fetch rejects with a TypeError. Real apps need to handle both. The post on handling loading and error states elegantly covers UI patterns for each.

One-Time Overrides

Pass { once: true } to make an override apply to a single request. That's handy for testing retry buttons:

test("retries after a failure", async () => {
  server.use(
    http.get(`${API}/todos`, () => new HttpResponse(null, { status: 503 }), {
      once: true,
    }),
  );
  // first request fails, the next one falls through to the default handler
});

Testing Loading States With delay

Responses from MSW resolve almost instantly, which can make loading states hard to observe. The delay helper adds latency:

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

server.use(
  http.get(`${API}/todos`, async () => {
    await delay(200);
    return HttpResponse.json([]);
  }),
);

Called with no argument, delay() uses a realistic random latency in the browser and no delay in Node. delay("infinite") never resolves, which is useful for asserting a spinner stays visible.

Asserting on Requests

Sometimes you need to check what the app sent: the body of a POST, a header, or query parameters. Capture the request inside a handler and assert on it afterwards. In this example, AddTodo posts through an API client that attaches a bearer token, which the test environment sets to test-token:

// src/features/todos/AddTodo.test.tsx
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { expect, test } from "vitest";
import { server } from "../../mocks/node";
import { API } from "../../mocks/handlers";
import { renderWithClient } from "../../test/render";
import { AddTodo } from "./AddTodo";

test("posts the new todo with an auth header", async () => {
  let captured: { body: unknown; auth: string | null } | undefined;

  server.use(
    http.post(`${API}/todos`, async ({ request }) => {
      captured = {
        body: await request.json(),
        auth: request.headers.get("Authorization"),
      };
      return HttpResponse.json({ id: 3, title: "Buy milk", completed: false }, { status: 201 });
    }),
  );

  const user = userEvent.setup();
  renderWithClient(<AddTodo />);

  await user.type(screen.getByLabelText("New todo"), "Buy milk");
  await user.click(screen.getByRole("button", { name: "Add" }));

  expect(await screen.findByText("Added “Buy milk”")).toBeInTheDocument();
  expect(captured).toEqual({
    body: { title: "Buy milk" },
    auth: "Bearer test-token",
  });
});

Assert on the request only when it matters to the feature, such as payload shape or auth. For most tests, checking the resulting UI is enough.

Query Parameters and Path Params

Path parameters come from params. Query parameters are read from the request URL, and they shouldn't be part of the handler's path pattern:

http.get(`${API}/search`, ({ request }) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q") ?? "";
  const page = Number(url.searchParams.get("page") ?? "1");

  const results = todos.filter((t) => t.title.toLowerCase().includes(q.toLowerCase()));
  return HttpResponse.json({ results, page });
}),

http.get(`${API}/todos/:id`, ({ params }) => {
  const todo = todos.find((t) => t.id === Number(params.id));
  return todo ? HttpResponse.json(todo) : new HttpResponse(null, { status: 404 });
}),

Writing handlers like this, with a bit of logic, lets you test search, pagination, and not-found screens against one consistent fake API.

Mocking GraphQL

MSW handles GraphQL with the graphql namespace. Handlers match by operation name:

import { graphql, HttpResponse } from "msw";

export const graphqlHandlers = [
  graphql.query("GetUser", ({ variables }) => {
    return HttpResponse.json({
      data: { user: { id: variables.id, name: "Ada Lovelace" } },
    });
  }),

  graphql.mutation("UpdateUser", () => {
    return HttpResponse.json({
      errors: [{ message: "Not authorized" }],
    });
  }),
];

Returning an errors array simulates GraphQL-level errors, which most clients surface differently from network errors.

Using the Same Handlers in the Browser

Because handlers are plain functions, you can reuse them during development to build UI before the backend exists, or to reproduce edge cases on demand. First, copy the worker script into your public folder:

npx msw init public --save

Create the worker:

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

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

Then start it before rendering, only in development and only when you opt in:

// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

async function enableMocking() {
  if (!import.meta.env.DEV || import.meta.env.VITE_MOCK_API !== "true") return;
  const { worker } = await import("./mocks/browser");
  await worker.start({ onUnhandledRequest: "bypass" });
}

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

The dynamic import keeps MSW out of your production bundle. Waiting for worker.start() before rendering ensures the first requests are intercepted. The browser worker uses bypass for unhandled requests, so assets and third-party scripts still load normally.

Common Mistakes With MSW

  • Forgetting resetHandlers. Overrides from one test leak into the next, causing failures that depend on test order.
  • Using relative URLs in Node. There's no page origin, so fetch("/api/todos") fails before MSW sees it. Use a base URL.
  • Allowing unhandled requests in tests. Without onUnhandledRequest: "error", a missing handler can silently hit a real server or hang.
  • Sharing a QueryClient between tests. Cached data hides your handler overrides. Create a new client per test.
  • Leaving retries on. TanStack Query retries failed requests with backoff, so error tests time out. Disable retries in tests.
  • Mutating fixture arrays in handlers. If a POST handler pushes into the shared todos array, later tests see the extra item. Copy data or reset it in afterEach.
  • Asserting on every request. Checking the UI is usually enough. Inspect request bodies only when the payload is the point of the test.

Frequently Asked Questions (FAQ) About MSW

Mocking the HTTP client ties your tests to an implementation detail. If you switch clients or add a data-fetching library, every mock breaks. MSW intercepts at the network level, so tests exercise your real fetching code, and the same handlers work in tests, the browser, and Storybook.

MSW 2 replaced the rest namespace with http and the req, res, ctx resolver arguments with standard Fetch API objects. Handlers now receive a request and return a Response, usually through HttpResponse. It also requires Node 18 or later, since it relies on the global Fetch API.

Yes. These libraries call fetch or another HTTP client under the hood, and MSW intercepts the resulting requests. Just make sure each test gets a fresh cache, and disable automatic retries so error-state tests finish quickly.

Put the happy-path handlers for your whole API in a shared file used by the server and the worker. Put scenario-specific overrides, like errors or empty responses, inside the tests that need them with server.use. That keeps defaults consistent and makes each test's special case visible.

You can run your app with the browser worker enabled during Playwright tests, but many teams prefer Playwright's own page.route API there, or a real test backend. End-to-end tests are most valuable when they exercise the real server, so mock sparingly at that level.

Generate types for your API from an OpenAPI or GraphQL schema and use them in your handlers, so a shape change causes a type error. Some teams also generate MSW handlers directly from OpenAPI specs. Pair this with a few end-to-end tests against the real backend to catch contract drift.

Conclusion

MSW lets you test React components against a fake server instead of a fake HTTP client. Define your API once as http handlers that return HttpResponse objects, start a server in your Vitest setup with onUnhandledRequest: "error", reset handlers after each test, and override them with server.use for empty, error, and slow responses. Your components, hooks, and data libraries run unchanged, which makes the tests both more realistic and more resilient to refactors.

Start by moving one test that mocks fetch or Axios over to MSW, then gradually build a shared handler file that describes your API. Once it exists, reuse it in the browser to develop against edge cases, and pair it with end-to-end testing with Playwright for full coverage against the real backend.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading