MSW (Mock Service Worker): API Mocking for Testing React Apps
If you've ever mocked an API by patching fetch, overriding axios.get, or wrestling with nock's obscure intercept API, you know the pain. Your mock and your real network call look nothing alike. The mock breaks when you refactor your fetching logic. And none of it works in the browser.
Mock Service Worker (MSW) solves this differently. Instead of patching JavaScript modules, it intercepts requests at the network level — in tests using a Node.js request interceptor, in the browser using a real Service Worker. Your application code makes a real fetch or axios call. MSW catches it before it leaves the process and returns whatever you defined. When you remove MSW, your app behaves identically. No monkey-patching, no cleanup logic, no surprises.
This guide covers MSW 2.x — the modern API that replaced rest.get with http.get — from setup through production-grade test patterns.
What MSW Is and How It Differs From Traditional Mocking
Traditional API mocking intercepts at the module level. You replace axios with a mock version, or you tell nock to intercept HTTP calls at the Node.js http module. This works, but it has structural problems:
- If you switch from
axiostofetch, your mocks break - The mock setup lives far from the component being tested
- Browser and Node environments need completely different mock strategies
- It's easy to forget cleanup, causing test pollution
MSW operates at a lower level. In Node (Jest, Vitest), it uses @mswjs/interceptors to intercept outgoing requests regardless of whether you use fetch, axios, node-fetch, or got. In the browser, it registers a Service Worker that proxies all network traffic. Your component calls fetch('/api/users') — MSW intercepts it, runs your handler, returns a mocked response. The component never knows.
The practical benefit: your test setup is decoupled from your HTTP client. You mock by URL and method, not by library API. This also means you can share handler definitions between your unit tests and your Storybook stories.
Setting Up MSW 2.x
Install MSW:
npm install msw --save-dev
# or
yarn add msw --devMSW 2.x changed the API significantly. The old rest.get is replaced by http.get. Import from the correct subpath:
// handlers.js
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'member' },
])
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params
return HttpResponse.json({ id: Number(id), name: 'Alice', role: 'admin' })
}),
]HttpResponse.json() is the new way to return JSON — it handles Content-Type headers automatically. You no longer call res(ctx.json(...)). The new API is just a function returning a Response.
Creating Request Handlers: GET, POST, PUT
MSW 2.x handlers are clean functions. Each handler receives a { request, params, cookies } object and returns a Response.
import { http, HttpResponse } from 'msw'
export const handlers = [
// GET with query params
http.get('/api/products', ({ request }) => {
const url = new URL(request.url)
const category = url.searchParams.get('category')
if (category === 'books') {
return HttpResponse.json([
{ id: 1, name: 'Clean Code', category: 'books' },
])
}
return HttpResponse.json([
{ id: 1, name: 'Clean Code', category: 'books' },
{ id: 2, name: 'Laptop Stand', category: 'hardware' },
])
}),
// POST — read the request body
http.post('/api/users', async ({ request }) => {
const body = await request.json()
if (!body.email) {
return HttpResponse.json(
{ error: 'Email is required' },
{ status: 400 }
)
}
return HttpResponse.json(
{ id: 42, email: body.email, name: body.name },
{ status: 201 }
)
}),
// PUT — update a resource
http.put('/api/users/:id', async ({ request, params }) => {
const body = await request.json()
return HttpResponse.json({
id: Number(params.id),
...body,
updatedAt: new Date().toISOString(),
})
}),
// DELETE
http.delete('/api/users/:id', ({ params }) => {
return new HttpResponse(null, { status: 204 })
}),
]Notice http.post uses await request.json() — the request object is a standard Fetch API Request. Everything you know about Request applies here.
Setting Up MSW in Jest / Vitest
For Node-based tests, create a server instance and wire it into the test lifecycle.
// src/mocks/server.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)Then in your test setup file:
// jest.setup.js or vitest.setup.js
import { server } from './src/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())Configure Jest to load this file:
// jest.config.js
module.exports = {
setupFilesAfterFramework: ['./jest.setup.js'],
}Or for Vitest:
// vitest.config.js
export default {
test: {
setupFiles: ['./vitest.setup.js'],
},
}The three lifecycle calls matter:
server.listen()— start intercepting before any tests run.onUnhandledRequest: 'error'makes unmatched requests throw, catching missing handlers early.server.resetHandlers()— remove any per-test overrides after each test. Without this, overrides bleed into subsequent tests.server.close()— clean up after the suite.
Setting Up MSW with React Testing Library
With the server wired up globally, your RTL tests just render and assert. No mock setup inside the test itself.
// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserList } from './UserList'
test('renders list of users', async () => {
render(<UserList />)
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for the data to arrive
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
expect(screen.getByText('Bob')).toBeInTheDocument()
})
test('filters users by role', async () => {
const user = userEvent.setup()
render(<UserList />)
await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Show admins only' }))
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.queryByText('Bob')).not.toBeInTheDocument()
})The component makes a real fetch('/api/users') call. MSW intercepts it and returns the data from your handler. No mocking inside the test, no dependency on how the component fetches data.
One-Time Handler Overrides for Specific Tests
Your global handlers define the happy path. For specific tests — errors, empty states, edge cases — override the handler for that test only.
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
test('shows empty state when no users exist', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json([])
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('No users found')).toBeInTheDocument()
})
})
test('shows error message when API fails', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('Failed to load users')).toBeInTheDocument()
})
})Because server.resetHandlers() runs after each test, these overrides are automatically removed. The next test gets the default handler. This is the core pattern for testing error states — override just enough for that scenario, then let MSW clean up.
Testing Error States and Loading States
Testing async states is where MSW earns its keep. Two scenarios you should always cover:
Network errors (not HTTP errors — actual network failures):
import { http, HttpResponse } from 'msw'
test('shows retry button on network failure', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.error()
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
})
})Loading state — use a delayed response to assert loading UI:
import { delay, http, HttpResponse } from 'msw'
test('shows skeleton while loading', async () => {
server.use(
http.get('/api/users', async () => {
await delay(200)
return HttpResponse.json([{ id: 1, name: 'Alice' }])
})
)
render(<UserList />)
// Assert loading state immediately
expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument()
// Wait for data
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument()
})The delay utility from MSW 2.x accepts milliseconds or 'infinite' (useful for testing permanent loading states).
MSW in the Browser: Storybook and Dev Mocking
MSW works in the browser through a Service Worker. This means you can use the same handlers in Storybook stories and in your local dev environment.
Initial browser setup:
npx msw init public/ --saveThis generates public/mockServiceWorker.js — the Service Worker file. Commit it.
Browser entry point:
// src/mocks/browser.js
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)Start in development (conditional on environment):
// src/main.jsx
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') return
const { worker } = await import('./mocks/browser')
return worker.start({
onUnhandledRequest: 'bypass', // let non-mocked requests through
})
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')).render(<App />)
})Storybook integration — add a global decorator:
// .storybook/preview.js
import { initialize, mswLoader } from 'msw-storybook-addon'
initialize()
export const loaders = [mswLoader]Then add handlers per story:
// UserList.stories.jsx
export const Empty = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () => HttpResponse.json([])),
],
},
},
}
export const WithError = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () => new HttpResponse(null, { status: 503 })),
],
},
},
}Each story gets isolated mock state. Your designer can click through every state of every component without a backend.
MSW vs axios-mock-adapter vs nock — When to Use Each
These tools solve different problems. Pick based on what you actually need.
MSW — use when:
- Testing React components with RTL
- You want to share mocks between tests and Storybook
- Your codebase uses multiple HTTP clients or might switch clients
- You need browser-compatible mocking
- You want mocks that resemble real API behavior
nock — use when:
- You're testing Node.js services, not frontend components
- You need to match by exact headers, query strings, or request bodies with no ceremony
- You're testing code that uses
http/httpsmodules directly - You don't need browser compatibility
// nock is concise for Node service tests
nock('https://api.github.com')
.get('/repos/octocat/hello-world')
.reply(200, { stargazers_count: 80 })axios-mock-adapter — use when:
- Your entire codebase uses axios, full stop
- You need to test axios-specific features (interceptors, cancel tokens)
- You want the simplest possible setup for an axios-only project
// axios-mock-adapter is tightly coupled to axios
const mock = new MockAdapter(axios)
mock.onGet('/users').reply(200, [{ id: 1, name: 'Alice' }])The tradeoff: axios-mock-adapter breaks if you add a non-axios fetch anywhere. MSW doesn't care.
Rule of thumb: frontend React app → MSW. Node microservice → nock. Legacy axios-heavy codebase with no browser testing → axios-mock-adapter.
Running Real E2E Tests With HelpMeTest After Your MSW Unit Tests Pass
MSW tests are fast and cover component behavior reliably. But they have a ceiling: they test your component against a mock API, not a real one. Your handlers are only as accurate as you make them.
Here's what MSW doesn't catch:
- Auth token expiry behavior you forgot to handle
- A backend field rename that your handler still returns with the old name
- Race conditions in your actual API
- Real network latency causing timeouts in production
This is where HelpMeTest picks up. After your MSW unit tests pass locally, HelpMeTest runs real browser automation against your deployed environment — the same way a user would.
Write a test in plain English:
Navigate to the users page
Verify the user list loads within 3 seconds
Click "Add user"
Fill in name "Test User" and email "test@example.com"
Click Submit
Verify the new user appears in the listHelpMeTest converts this to Playwright automation and runs it against your real staging URL on a schedule. If the API changes, if a deploy breaks the auth flow, if a database migration drops a column — the E2E test catches it. Your MSW tests won't.
The two layers are complementary:
- MSW — fast feedback on component logic, runs in CI in seconds, no real network required
- HelpMeTest — real browser, real API, real user flows, runs continuously against your deployed app
HelpMeTest's free plan covers 10 tests with 24/7 monitoring. The Pro plan is $100/month for unlimited tests. Setup takes minutes — point it at your URL and describe what you want to verify.
Putting It Together
A solid MSW setup for a React project looks like this:
src/
mocks/
handlers.js — shared handler definitions
server.js — Node server for Jest/Vitest
browser.js — browser worker for dev + Storybook
components/
UserList/
UserList.jsx
UserList.test.jsx — RTL tests, no manual mockingThe handlers.js file is your single source of truth for mock API behavior. It's used in unit tests, in Storybook stories, and optionally in local development. When the real API changes shape, you update handlers once.
If you're on MSW 1.x with rest.get, migration to 2.x is worth doing now. The new http.* API is simpler, the HttpResponse object maps directly to the Fetch API, and the TypeScript types are significantly better.
Start with the global happy-path handlers. Add per-test overrides for every error state your component handles. Then add a HelpMeTest suite for the user flows that matter most — the ones where a broken API in production would cost you customers. That combination covers the full testing surface without slowing down your CI loop.
Stop mocking at the module level. MSW gives you network-level interception that works the same in tests, in Storybook, and in the browser. Set it up once, define handlers once, test every state your app can reach.
Ready to add real E2E coverage on top of your MSW unit tests? Get started with HelpMeTest free — 10 tests, 24/7 monitoring, no credit card required.