Mocking APIs with MSW: Handlers for the Browser and Node Tests, Runtime Overrides and GraphQL

Key takeaways

MSW (Mock Service Worker) intercepts requests at the network level using Service Workers. It provides seamless API mocking for development and testing.

Introduction

MSW (Mock Service Worker) is an API mocking library that intercepts requests at the network level. Unlike traditional mocking, MSW uses Service Workers to intercept actual HTTP requests.

This post starts from the limits of module-level mocks like jest.mock('axios') and shows how MSW’s network-level handlers avoid them. It covers defining handlers, wiring the same handlers into the browser and into Node.js tests, per-test overrides with server.use(), error and delay simulation, GraphQL operations, a stateful e-commerce example, and how to debug requests that are not being mocked.

Traditional Mocking

// Tightly coupled to implementation
jest.mock('axios');
axios.get.mockResolvedValue({ data: { name: 'Alice' } });

This kind of mock is tied to one HTTP library: switch a component from axios to fetch and the mock silently stops applying, and it only exists inside the test runner, so it cannot help during local development in the browser.

MSW

// Intercepts at network level
rest.get('/api/user', (req, res, ctx) => {
  return res(ctx.json({ name: 'Alice' }));
});

The core architectural difference is worth being explicit about: jest.mock('axios') replaces the module — it patches what import axios from 'axios' resolves to, which means it only works for code that actually imports that specific module, and breaks the moment a component switches from axios to fetch or a different HTTP client. MSW instead intercepts at the network boundary itself (a Service Worker in the browser, request interception at the Node.js level in tests) — from the application code’s point of view, a real HTTP request goes out and a real HTTP response comes back, it just happens to be MSW answering instead of an actual server. That’s what makes the same handler definitions reusable for local development (no backend running yet) and for automated tests, rather than needing separate mocking strategies for each.

Installation

npm install --save-dev msw

Browser Setup

npx msw init public/ --save

This creates public/mockServiceWorker.js. That generated file is the actual browser Service Worker script MSW registers to intercept fetch calls at the network layer — it’s not something you write or edit by hand, and it needs to be regenerated (by rerunning msw init) whenever you upgrade MSW to a version with a breaking Service Worker protocol change, which the library’s changelog will call out explicitly.

Defining Handlers

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

export const handlers = [
  // GET request
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: 1,
      name: 'Alice',
      email: '[email protected]',
    });
  }),

  // POST request
  http.post('/api/login', async ({ request }) => {
    const { email, password } = await request.json();
    
    if (email === '[email protected]' && password === 'password') {
      return HttpResponse.json({ token: 'fake-token' });
    }
    
    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }),

  // Dynamic path
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id, name: `User ${id}` });
  }),
];

A handler’s job is to match a request pattern and return a Response, and HttpResponse (MSW’s response builder) intentionally mirrors the real, standard Response API rather than inventing its own — HttpResponse.json(...) is functionally equivalent to constructing a Response with a JSON body and the right Content-Type header, which is why code that consumes these mocked responses (res.json(), checking res.status) doesn’t need to know or care whether the response came from MSW or a real server. :id in the path is a URL pattern parameter, matched against the actual request path and made available on params — this is the same path-matching syntax as most server-side routers (Express, React Router), which is deliberate, since these handlers are meant to model a real API’s routing structure.

Browser Integration

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

export const worker = setupWorker(...handlers);
// main.tsx
import { worker } from './mocks/browser';

if (import.meta.env.DEV) {
  worker.start({
    onUnhandledRequest: 'warn',
  });
}

ReactDOM.createRoot(document.getElementById('root')!).render(<App />);

Now all API requests are mocked in development! import.meta.env.DEV gating this is important and easy to overlook: MSW is explicitly a development/testing-only tool (as the FAQ notes), and starting the Service Worker in a production build would mean real user requests get intercepted and answered with fake mock data instead of hitting your actual backend — a build-time environment check like this is what keeps the mocking layer from ever accidentally shipping to real users. onUnhandledRequest: 'warn' is worth setting explicitly too: by default, a request that doesn’t match any handler is passed through silently, which can mask a typo’d URL or a genuinely missing handler; 'warn' at least surfaces it in the console instead of failing invisibly.

Node.js Integration (Tests)

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

export const server = setupServer(...handlers);
// tests/setup.ts (Jest/Vitest)
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

setupServer (Node.js) and setupWorker (browser) both consume the exact same handlers array — that shared-handlers file is the whole point of the architecture, and it’s why the file layout in the Best Practices section below deliberately keeps handlers.ts separate from browser.ts/server.ts. The three lifecycle hooks matter for test isolation specifically: server.listen() once before the whole suite starts interception, server.resetHandlers() after every test discards any per-test overrides (covered in the Runtime Request Handlers section) so one test’s custom mock can’t leak into the next test and cause confusing, order-dependent failures, and server.close() releases the interception cleanly once the suite finishes.

Testing with MSW

// UserProfile.tsx
export function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);
  
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('displays user name', async () => {
  render(<UserProfile userId={1} />);
  
  expect(await screen.findByText('User 1')).toBeInTheDocument();
});

Notice this test never mocks fetch itself, never stubs UserProfile’s internals, and never reaches into React’s state — it renders the real component with its real fetch call, and MSW answers that call transparently underneath. screen.findByText (rather than getByText) is what makes this work despite the request being genuinely asynchronous: findBy* queries return a promise and retry until the element appears or a timeout elapses, which is necessary here because the component starts in a “Loading…” state and only renders the name after the mocked fetch resolves — a plain synchronous getByText would run before that resolution and fail.

Response Modifiers

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

export const handlers = [
  // Delayed response
  http.get('/api/slow', async () => {
    await delay(2000);
    return HttpResponse.json({ message: 'Slow response' });
  }),

  // Error response
  http.get('/api/error', () => {
    return HttpResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }),

  // Network error
  http.get('/api/network-error', () => {
    return HttpResponse.error();
  }),

  // Custom headers
  http.get('/api/with-headers', () => {
    return HttpResponse.json(
      { data: 'value' },
      {
        headers: {
          'X-Custom-Header': 'custom-value',
          'Cache-Control': 'no-cache',
        },
      }
    );
  }),
];

These four modifiers cover the failure modes real APIs actually exhibit, and each tests a genuinely different thing in your application: delay() lets you verify loading states actually render and don’t flash-and-disappear too fast to notice (a common bug that only surfaces against a real slow network); a 500 status with an error body tests your app’s handling of a server that responded but reported failure; HttpResponse.error() simulates the network itself failing (DNS failure, connection refused, offline) — a meaningfully different code path than a 500 response, since fetch doesn’t even resolve normally in that case, it rejects; and custom headers let you test response-header-dependent logic (caching behavior, custom auth headers) without needing a real server configured to send them.

Runtime Request Handlers

// Override handler for specific test
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

test('handles server error', async () => {
  server.use(
    http.get('/api/user', () => {
      return HttpResponse.json(
        { error: 'Server error' },
        { status: 500 }
      );
    })
  );
  
  // Test error handling...
});

server.use() is what makes per-test customization possible without duplicating the entire handlers file: it pushes a handler onto the front of the resolution order for the rest of the current test only, so requests matching it get this overridden response instead of the default one defined in handlers.ts — and because afterEach(() => server.resetHandlers()) from the setup file runs after every test, this override doesn’t persist into the next test. This pattern — real handlers as the default “happy path,” per-test server.use() overrides for the edge cases each specific test cares about — is the idiomatic way to test both success and failure states against the same component without maintaining two entirely separate mock configurations.

GraphQL Support

import { graphql, HttpResponse } from 'msw';

export const handlers = [
  graphql.query('GetUser', ({ variables }) => {
    return HttpResponse.json({
      data: {
        user: {
          id: variables.id,
          name: 'Alice',
        },
      },
    });
  }),

  graphql.mutation('CreatePost', ({ variables }) => {
    return HttpResponse.json({
      data: {
        createPost: {
          id: Date.now(),
          title: variables.title,
        },
      },
    });
  }),
];

This is worth a moment because it looks similar to the REST handlers above but is matching on something structurally different: GraphQL typically sends every request to a single endpoint (commonly /graphql) as a POST, so there’s no URL path to route on the way http.get('/api/users/:id', ...) does — instead, graphql.query('GetUser', ...) matches by the operation name embedded in the GraphQL request body itself. variables gives access to whatever arguments the client’s query actually passed (the id in GetUser($id: ID!), for instance), which is MSW parsing the GraphQL request payload for you rather than something you’d have to extract manually from the raw POST body.

Real-World Example: E-commerce API

// mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw';

interface Product {
  id: number;
  name: string;
  price: number;
}

const products: Product[] = [
  { id: 1, name: 'Laptop', price: 999 },
  { id: 2, name: 'Mouse', price: 29 },
  { id: 3, name: 'Keyboard', price: 79 },
];

let cart: { productId: number; quantity: number }[] = [];

export const handlers = [
  // Get products
  http.get('/api/products', async () => {
    await delay(500);
    return HttpResponse.json({ products });
  }),

  // Get product by ID
  http.get('/api/products/:id', ({ params }) => {
    const product = products.find(p => p.id === Number(params.id));
    
    if (!product) {
      return HttpResponse.json(
        { error: 'Product not found' },
        { status: 404 }
      );
    }
    
    return HttpResponse.json({ product });
  }),

  // Add to cart
  http.post('/api/cart', async ({ request }) => {
    const { productId, quantity } = await request.json();
    
    const existing = cart.find(item => item.productId === productId);
    
    if (existing) {
      existing.quantity += quantity;
    } else {
      cart.push({ productId, quantity });
    }
    
    return HttpResponse.json({ cart }, { status: 201 });
  }),

  // Get cart
  http.get('/api/cart', () => {
    return HttpResponse.json({ cart });
  }),

  // Clear cart
  http.delete('/api/cart', () => {
    cart = [];
    return HttpResponse.json({ success: true });
  }),
];

This example demonstrates handlers holding real, mutable, in-memory state (cart) across requests, which is what makes it possible to model a genuinely stateful flow (add to cart, then fetch the cart, and see the addition reflected) rather than every handler returning static canned data. It’s worth flagging the test-isolation trap this introduces, though: server.resetHandlers() (used earlier in the Node.js setup) only resets handler overrides added via server.use() — it does nothing to this module-level cart array, so without an explicit reset (cart = [] in a beforeEach, or restructuring state to live inside a fixture reset per test), items added to the cart in one test will still be there at the start of the next, a classic source of order-dependent test flakiness.

Keeping mocks honest: shared handlers, real shapes, error paths

Share handlers between dev and tests

mocks/
├── handlers.ts      # Shared handlers
├── browser.ts       # Browser setup
└── server.ts        # Node.js setup

This layout isn’t just organizational tidiness — it’s what guarantees dev-mode mocking and test mocking can never silently drift apart. If handlers.ts were duplicated (one copy imported by browser.ts, a slightly different one by server.ts), a fix or a new endpoint added to one could easily be forgotten in the other, and a test could pass against a mock shape that no longer matches what development actually sees.

Mirror the real response shape

// Good: realistic
http.get('/api/user', () => {
  return HttpResponse.json({
    id: 1,
    name: 'Alice Johnson',
    email: '[email protected]',
    createdAt: '2024-01-15T10:30:00Z',
  });
});

// Bad: minimal
http.get('/api/user', () => {
  return HttpResponse.json({ name: 'test' });
});

Minimal mock data papers over UI bugs that only appear with a realistic payload shape — a component that renders user.createdAt.toLocaleDateString() will pass every test against { name: 'test' } and then crash the moment it meets a real API response that includes a createdAt field the mock never exercised. Mocks that mirror your actual API’s response shape (all the fields, realistic-looking values, correct types) catch these gaps before a real backend does.

Cover the error paths

test('handles network error', async () => {
  server.use(
    http.get('/api/user', () => {
      return HttpResponse.error();
    })
  );
  
  render(<UserProfile />);
  expect(await screen.findByText('Network error')).toBeInTheDocument();
});

Because the happy-path handlers already cover successful responses, it’s tempting to consider testing done — but error paths (a 500, a network failure, a validation error) are exactly the code a team is least likely to have manually exercised by hand during development, and exactly the code most likely to have a bug (a missing null check, an unhandled promise rejection, an error message that never actually renders). server.use() overriding just the one endpoint under test, as shown here, is cheap enough that there’s little excuse not to write at least one error-path test per component that fetches data.

Debugging requests that are not mocked

// Tests: fail loudly on any request no handler covers
server.listen({ onUnhandledRequest: 'error' });

// Temporarily log every request MSW sees, without changing responses
server.events.on('request:start', ({ request }) => {
  console.log('MSW saw:', request.method, request.url);
});

onUnhandledRequest is the first thing to check when a test or a dev-mode feature mysteriously is not getting mocked data. With 'warn' MSW prints each request that matched no handler; in tests 'error' is usually better, because a warning scrolls past while the test fails later with a confusing assertion. The cause is almost always a typo in the URL, a path pattern that does not match (/api/users/:id will not match a request missing the id segment), a different origin than the handler assumes, or a handler not yet written. The life-cycle event listener prints every request MSW intercepts without affecting responses, which is safer than adding a catch-all logging handler: a catch-all such as http.get('/api/*', ...) placed at the top of the handler list answers every matching request with its own placeholder body and hides the real handlers behind it. Remove the listener once the mismatch is found.

One shared handler set, per-test overrides

MSW mocks at the network boundary, so application code makes real fetch/axios calls and gets standard Response objects back, whether it runs in the browser during development or in Node.js under Jest or Vitest. Keep one shared handlers.ts as the happy path, override single endpoints per test with server.use(), and remember that resetHandlers() does not reset any in-memory state your handlers keep.

Documentation: