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.
Table of Contents
- 1. Why MSW instead of simply mocking fetch
- 2. Setup: handlers, server and test integration
- 3. Writing request handlers for REST endpoints
- 4. Simulating error states, latency and network failures
- 5. Mocking GraphQL operations with MSW
- 6. Overriding handlers per test instead of duplicating them
- 7. MSW in the browser: service worker for local development
- 8. Common pitfalls when introducing MSW
- 9. MSW compared to other mocking strategies
- 10. Summary
- 11. FAQ
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.