MSW: Mock Service Worker for React API Tests Without a Backend
AI generated
</>
{ }
React · Testing · MSW · API Mocking
MSW: Mock Service Worker for React API Tests
without a backend, without fragile fetch mocks

Anyone testing React components that load data faces the same question: mock fetch or test against a real backend. MSW (Mock Service Worker) intercepts network requests at the protocol level, so components execute exactly the code they also run in production, while responses stay fully under control.

17 min read MSW 2 · Vitest · Testing Library REST & GraphQL

1. Why MSW instead of simply mocking fetch

Classic mocking replaces global.fetch or the axios module with a Jest or Vitest mock function that returns a hardcoded value. The problem: the component never actually calls its own data access layer. If the structure of fetch(url, options) changes, an extra header gets added, or the project switches from axios to the native Fetch API, all module mocks stay green even though the actual code has long been broken. MSW solves this by intercepting the network layer itself instead of the module, using the same request handlers in Node tests and in the real browser.

The key architectural difference: MSW registers an interceptor at the http.request level in Node, or an actual service worker in the browser. The component under test therefore executes its complete, unmodified data-fetching code, including retry logic, header construction and response parsing. Only the response on the wire gets replaced by MSW. This makes tests more meaningful, because a bug in the actual fetch implementation is no longer hidden by the mocking.

Another benefit of MSW is handler reuse. The same request handlers that run in unit and integration tests can be used unchanged for local development without a backend, for Storybook stories, and in some cases even for demo environments. This reduces duplication between test fixtures and mock data that would otherwise need to be maintained separately in multiple corners of the project.

2. Setup: handlers, server and test integration

Getting started with MSW follows a fixed pattern: define handlers, set up a server for Node tests, and register it in the test lifecycle. Installation happens via npm install msw --save-dev, after which handlers are collected centrally in their own file. For Vitest or Jest, setupServer from msw/node is additionally imported, which manages the interceptor for the whole test suite.

The lifecycle matters: server.listen() before all tests, server.resetHandlers() after each individual test, and server.close() after the whole suite. Without resetHandlers after every test, handler overrides from one test can leak into the next, leading to hard-to-trace, test-order-dependent failures. The option onUnhandledRequest: "error" is effectively mandatory in practice, because it immediately surfaces when a component sends a request to an endpoint with no registered handler.


// mocks/handlers.ts — central request handler registry
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params
    return HttpResponse.json({ id, name: 'Ada Lovelace', role: 'admin' })
  }),
]

// mocks/server.ts — Node server for test environment
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

// vitest.setup.ts — lifecycle wiring
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'

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

3. Writing request handlers for REST endpoints

A request handler in MSW is a function that binds an HTTP method and path to a resolver. The resolver gets access to path parameters, query parameters, request body and headers, and returns an HttpResponse. This symmetry with real server handlers is deliberate, so developers with Express or Fastify experience are productive immediately. For nested resources like /api/orders/:orderId/items, MSW supports the same path-to-regexp syntax used by common routers.

A frequent pattern is mocking a paginated list where query parameters like page and limit are evaluated to return a realistic slice of the data. This matters because components that test pagination can otherwise never verify with static fixtures whether the page logic actually works. MSW gives full access to request.url.searchParams, so the handler behaves like a mini backend.


// mocks/handlers.ts — paginated list endpoint with query params
import { http, HttpResponse } from 'msw'

const allProducts = Array.from({ length: 47 }, (_, i) => ({
  id: i + 1,
  name: `Product ${i + 1}`,
  price: 9.99 + i,
}))

export const handlers = [
  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url)
    const page = Number(url.searchParams.get('page') ?? '1')
    const limit = Number(url.searchParams.get('limit') ?? '10')
    const start = (page - 1) * limit

    return HttpResponse.json({
      items: allProducts.slice(start, start + limit),
      total: allProducts.length,
      page,
    })
  }),

  http.post('/api/products', async ({ request }) => {
    const body = await request.json() as { name: string; price: number }
    return HttpResponse.json({ id: 48, ...body }, { status: 201 })
  }),
]

4. Simulating error states, latency and network failures

The biggest practical benefit of MSW shows up when testing error paths. A component that shows a loading indicator, an error message and a retry button needs tests for all three states, not just the success case. With MSW, a handler can be switched per test to a 500 status, a 401 followed by a redirect flow, or a completely failed connection, without touching production logic.

For realistic loading states, MSW supports artificial delay via delay() from the core package. This is especially valuable for uncovering race conditions, for example when a user quickly switches between two detail pages and the response to the first request arrives later than the response to the second. Without controlled delay, such bugs stay completely invisible in tests that always resolve synchronously.


// user-profile.test.tsx — testing error and slow-network states
import { http, HttpResponse, delay } from 'msw'
import { render, screen, waitFor } from '@testing-library/react'
import { server } from '../mocks/server'
import { UserProfile } from './UserProfile'

test('shows error message on 500 response', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json({ message: 'Internal error' }, { status: 500 })
    })
  )

  render(<UserProfile userId="42" />)

  await waitFor(() => {
    expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
  })
})

test('shows loading spinner while request is pending', async () => {
  server.use(
    http.get('/api/users/:id', async () => {
      await delay(200)
      return HttpResponse.json({ id: '42', name: 'Ada Lovelace' })
    })
  )

  render(<UserProfile userId="42" />)
  expect(screen.getByRole('status')).toBeInTheDocument()

  await waitFor(() => {
    expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
  })
})

test('handles network failure gracefully', async () => {
  server.use(http.get('/api/users/:id', () => HttpResponse.error()))
  render(<UserProfile userId="42" />)

  await waitFor(() => {
    expect(screen.getByText(/connection failed/i)).toBeInTheDocument()
  })
})

5. Mocking GraphQL operations with MSW

Besides REST, MSW also supports GraphQL natively through its own handler API that matches on operation name instead of URL. This matters because GraphQL requests almost always hit the same /graphql endpoint and only differ in the query or mutation name. The handler graphql.query('GetUser', resolver) matches every request with this operation name, regardless of additional fields in the query.

For projects using Apollo Client or urql, this approach is considerably more robust than mocking the GraphQL client itself, because the client's caching behavior, error links and retry mechanisms continue to run for real. Variables from a mutation can be read via variables inside the resolver, so parameterized mutations such as creating a comment can also be answered realistically.


// mocks/handlers.ts — GraphQL query and mutation handlers
import { graphql, HttpResponse } from 'msw'

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

  graphql.mutation('CreateComment', ({ variables }) => {
    return HttpResponse.json({
      data: {
        createComment: { id: 'c-1', text: variables.text, createdAt: new Date().toISOString() },
      },
    })
  }),

  graphql.query('GetUser', ({ variables }) => {
    if (variables.id === 'missing') {
      return HttpResponse.json({
        errors: [{ message: 'User not found' }],
      })
    }
  }),
]

6. Overriding handlers per test instead of duplicating them

A common beginner mistake with MSW is creating a completely new handler file for every single test case. Instead, the base handler list should cover the typical success cases, while individual tests use server.use(...) to override a handler specifically for the duration of that one test. Because resetHandlers() runs after every test, the server automatically falls back to the base handlers afterward.

This pattern keeps handler definitions DRY and makes it immediately visible which test deviates from the norm. For more complex scenarios where several consecutive requests need to return different responses, for example a first failing attempt followed by a successful retry, MSW supports chaining multiple http.get calls with { once: true }, so each call only applies once before the next registered handler takes over.


// retry-logic.test.tsx — first call fails, second call succeeds
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'

test('retries once after a failed request', async () => {
  let callCount = 0

  server.use(
    http.get('/api/orders', () => {
      callCount += 1
      if (callCount === 1) {
        return HttpResponse.json({ message: 'timeout' }, { status: 504 })
      }
      return HttpResponse.json({ items: [] })
    })
  )

  // Component under test performs its own retry on 504
  render(<OrdersList />)
  await waitFor(() => expect(callCount).toBe(2))
})

7. MSW in the browser: service worker for local development

Via mocks/browser.ts and setupWorker from msw/browser, the same handler list can also be registered in the real browser. This is especially valuable for frontend teams developing in parallel with a backend whose endpoints are not yet implemented. The service worker is copied into the project as a static file via npx msw init public/ and must be served by the development server.

A practical benefit of this setup: new features can be built independently of backend progress and demonstrated in Storybook, without a real API team sitting blocking on the critical path. Once the real backend is available, the worker is simply disabled, without needing to adapt component code, because the same fetch layer remains unchanged.

8. Common pitfalls when introducing MSW

The most common mistake with MSW is a missing await before asynchronous assertions. Because MSW resolves requests through real promise chains, every expectation that waits on a network response must be written with waitFor or findBy* queries from Testing Library. A synchronous getByText right after render() runs into a race condition, because the mock server's response has not yet arrived.

A second pitfall concerns absolute versus relative URLs. If the application calls fetch("https://api.example.com/users") with a full domain, but the handler is only registered for /users, the mock never fires. The fix is either to mirror the base URL in the handlers or to force a relative base URL in tests via an environment mock. A third pitfall is forgetting onUnhandledRequest: "error", which lets un-mocked requests silently trigger real network calls and makes tests fail unpredictably in CI environments without internet access.

9. MSW compared to other mocking strategies

Choosing the right mocking strategy depends on the test goal. MSW is the right choice when the actual fetch implementation should be exercised, while module mocks can still make sense for very isolated unit tests of individual functions.

Strategy What gets replaced Advantage Drawback
jest.mock('axios') The entire HTTP module Very fast, no network layer Does not test the real request construction
MSW (Node) Network layer, not the module Realistic, client code stays untouched Slightly more setup effort
Real test backend Nothing, fully real Maximum realism Slow, flaky, hard to isolate
MSW (browser, dev) Service worker intercepts real requests Frontend development without backend blocking Worker setup needed, not for production

In practice, successful teams combine both approaches: MSW for all tests covering data flow and UI states, plus classic unit tests without any network for pure utility functions. This combination keeps the test suite fast while critical integration paths remain realistically covered.

10. Summary

MSW shifts API mocking from the module level to the network level, letting React components execute their complete, unmodified data-fetching code. Request handlers for REST and GraphQL are defined centrally, overridden per test via server.use(), and thereby realistically cover success cases, error states, latency and race conditions. The same handlers work identically in Node tests and in the browser via a real service worker.

The biggest gain over classic fetch mocks lies in test depth: a bug in request construction, retry logic or header handling becomes visible with MSW, whereas it would go undetected with module mocks. Teams that consistently adopt MSW report noticeably fewer production-specific API bugs, because the tests exercise the same layer that runs in production.

MSW for React API Tests — Key Takeaways

Network instead of module

MSW intercepts requests at the HTTP level, the component's real fetch code stays unchanged and gets tested along with it.

Realistic error states

500s, 401s, network failure and latency via delay() can be simulated per test without changing production code.

REST and GraphQL

A unified handler API for both protocols, GraphQL matches on operation name instead of URL.

Node and browser identical

The same handlers run in the test suite and as a service worker for local development without a backend.

11. FAQ: MSW for React API Tests

1MSW vs. jest.mock('axios')?
jest.mock replaces the whole module, the real request code never runs. MSW intercepts at the network level, headers and URL construction stay real.
2Does MSW work with GraphQL?
Yes, its own graphql handler API that matches on operation name instead of URL.
3Simulate a server error for one test only?
server.use() inside the test, then resetHandlers() restores automatically afterward.
4Handler fails despite a correct path?
Usually the base URL does not match, check absolute vs. relative URL.
5Need MSW in the browser too?
Not necessarily, but valuable for development without a finished backend via a service worker.
6Test race conditions with MSW?
delay() from the core package adds controlled latency, making race-condition bugs visible.
7What does onUnhandledRequest: 'error' do?
Test fails immediately if a request has no registered handler, instead of silently going through for real.
8File uploads testable?
Yes, via request.formData() in the resolver, reflected in the mock response.
9Handlers centrally or per file?
Centrally for success cases, targeted overrides via server.use() per test, keeps everything DRY.
10Does MSW replace real E2E tests?
No, MSW covers frontend logic and meaningfully complements E2E tests against a real backend.