MSW: API Mocking for React Tests and Development (2026)
Mock Service Worker (MSW) intercepts network requests at the Service Worker level in the browser and at the Node.js level in tests. Unlike mocking fetch or axios directly, MSW lets your components use real networking code while controlling what comes back — the same handlers work in Vitest, Jest, Storybook, and the browser.
Table of Contents
Setup
npm install msw --save-dev
# Generate the Service Worker file for browser usage
npx msw init public/ --save
Writing Handlers
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
// GET — return JSON
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
])
}),
// GET with path params
http.get('/api/users/:id', ({ params }) => {
const { id } = params
if (id === '404') {
return new HttpResponse(null, { status: 404 })
}
return HttpResponse.json({
id,
name: 'Alice',
email: 'alice@example.com',
role: 'admin',
createdAt: '2026-01-01T00:00:00Z',
})
}),
// POST — read request body
http.post('/api/users', async ({ request }) => {
const body = await request.json() as { name: string; email: string }
return HttpResponse.json(
{ id: crypto.randomUUID(), ...body, role: 'user', createdAt: new Date().toISOString() },
{ status: 201 }
)
}),
// PUT
http.put('/api/users/:id', async ({ params, request }) => {
const body = await request.json()
return HttpResponse.json({ id: params.id, ...body })
}),
// DELETE
http.delete('/api/users/:id', ({ params }) => {
return HttpResponse.json({ deleted: true, id: params.id })
}),
// Query params
http.get('/api/products', ({ request }) => {
const url = new URL(request.url)
const category = url.searchParams.get('category')
const page = Number(url.searchParams.get('page') ?? 1)
const products = mockProducts.filter(p => !category || p.category === category)
return HttpResponse.json({
data: products.slice((page - 1) * 10, page * 10),
total: products.length,
})
}),
]
Browser Integration
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
// src/main.tsx — start MSW only in development
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') return
const { worker } = await import('./mocks/browser')
return worker.start({
onUnhandledRequest: 'warn', // Warn about unmocked requests
})
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
})
Node.js Test Setup
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/setupTests.ts (referenced from vitest.config.ts or jest.config.ts)
import { server } from './mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers()) // Restore defaults between tests
afterAll(() => server.close())
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
globals: true,
},
})
Testing with Vitest
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import { UserList } from './UserList'
test('displays users from API', async () => {
render(<UserList />)
// Wait for async data to load
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
test('shows empty state when no users', async () => {
// Override handler for this specific test
server.use(
http.get('/api/users', () => {
return HttpResponse.json([])
})
)
render(<UserList />)
expect(await screen.findByText('No users found')).toBeInTheDocument()
})
test('shows error message on API failure', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 })
})
)
render(<UserList />)
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load users')
})
test('creates a new user', async () => {
const user = userEvent.setup()
render(<CreateUserForm />)
await user.type(screen.getByLabelText('Name'), 'Charlie')
await user.type(screen.getByLabelText('Email'), 'charlie@example.com')
await user.click(screen.getByRole('button', { name: 'Create User' }))
expect(await screen.findByText('User created successfully')).toBeInTheDocument()
})
test('handles network timeout', async () => {
server.use(
http.get('/api/users', async () => {
// Simulate a slow or hanging response
await new Promise(() => {}) // Never resolves
})
)
render(<UserList />)
// Test that loading state is shown
expect(screen.getByRole('status')).toBeInTheDocument()
})
Simulating Errors
import { http, HttpResponse } from 'msw'
// HTTP error responses
http.get('/api/data', () => new HttpResponse(null, { status: 401 }))
http.get('/api/data', () => new HttpResponse(null, { status: 403 }))
http.get('/api/data', () => new HttpResponse(null, { status: 404 }))
http.get('/api/data', () => new HttpResponse(null, { status: 429, headers: { 'Retry-After': '60' } }))
http.get('/api/data', () => new HttpResponse(null, { status: 500 }))
// JSON error body
http.post('/api/users', () =>
HttpResponse.json(
{ code: 'VALIDATION_ERROR', message: 'Email already exists', field: 'email' },
{ status: 422 }
)
)
// Network error (no response — simulates connection refused)
import { NetworkError } from 'msw'
http.get('/api/data', () => { throw new NetworkError() })
// Delayed response (simulate slow API)
http.get('/api/data', async () => {
await new Promise(resolve => setTimeout(resolve, 2000))
return HttpResponse.json({ data: 'slow response' })
})
// Passthrough (let specific requests hit real API)
http.get('https://fonts.googleapis.com/*', () => {
return passthrough()
})
GraphQL Handlers
import { graphql, HttpResponse } from 'msw'
export const graphqlHandlers = [
graphql.query('GetUsers', () => {
return HttpResponse.json({
data: {
users: [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
],
},
})
}),
graphql.query('GetUser', ({ variables }) => {
const { id } = variables
return HttpResponse.json({
data: {
user: { id, name: 'Alice', email: 'alice@example.com' },
},
})
}),
graphql.mutation('CreateUser', ({ variables }) => {
const { input } = variables
return HttpResponse.json({
data: {
createUser: { id: crypto.randomUUID(), ...input },
},
})
}),
// Simulate GraphQL error
graphql.query('GetUsers', () => {
return HttpResponse.json({
errors: [{ message: 'Unauthorized', extensions: { code: 'UNAUTHENTICATED' } }],
})
}),
]
Storybook Integration
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon'
initialize({ onUnhandledRequest: 'bypass' })
export default {
loaders: [mswLoader],
}
// UserList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { http, HttpResponse } from 'msw'
import { UserList } from './UserList'
const meta: Meta<typeof UserList> = {
component: UserList,
}
export default meta
type Story = StoryObj<typeof UserList>
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () =>
HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
])
),
],
},
},
}
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () => HttpResponse.json([])),
],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users', async () => {
await new Promise(() => {}) // Never resolves — shows skeleton
}),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () => new HttpResponse(null, { status: 500 })),
],
},
},
}
One handler set, everywhere: The same handlers file works in Vitest tests (via
setupServer), Storybook stories (via msw-storybook-addon), and your browser dev environment (via setupWorker). Organize shared handlers in src/mocks/handlers.ts and override per-test or per-story with server.use() or parameters.msw.handlers.