MSW (Mock Service Worker): The Standard for API Mocking
Prologue: The Mock That Drifted from the Real API
Tests passed perfectly. Staging was fine. Two months later, we discovered the API response structure had changed in production. The tests had been using stale mock data instead of the actual API.
The root cause was the mocking approach. When you mock at the library level — vi.mock('axios') or global.fetch = jest.fn() — actual network requests never happen. And the mocking strategy changes depending on whether your component uses fetch or axios.
MSW (Mock Service Worker) solves this at the network level. The component sends a real HTTP request. MSW intercepts it in the middle and returns a fake response. It works identically regardless of which HTTP client you use.
How MSW Differs from Other Approaches
Problems with Existing Methods
// Method 1: Direct fetch mock
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ users: [] }),
})
// Problem: Replaces ALL fetch calls, not just the ones you want
// Problem: Real Response behavior (headers, status codes) doesn't work
// Method 2: axios mock
vi.mock('axios')
vi.mocked(axios.get).mockResolvedValue({ data: { users: [] } })
// Problem: Breaks if you switch HTTP clients
// Problem: Couples test code to implementation (axios specifically)
// Method 3: Mock the API function directly
vi.mock('./api/users')
vi.mocked(getUsers).mockResolvedValue([])
// Problem: Skips the entire network layer
// Problem: The actual API call code isn't tested at all
MSW's Approach
Component → fetch('/api/users') → [Browser Service Worker / Node.js interceptor]
↓
MSW Handler
↓
Returns fake response
The component sends real HTTP requests unchanged. Since interception happens at the network layer:
- Works identically with any HTTP client (fetch, axios, ky, got...)
- Request headers, query parameters, and body parsing work like the real thing
- Network errors and timeouts can be simulated
- Visible in the browser's Network tab (browser mode)
Installation and Setup
npm install msw --save-dev
This guide covers MSW 2.x.
Defining Handlers
Handlers are shared between browser and Node.js environments:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
// GET request
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' },
])
}),
// Dynamic URL parameters
http.get('/api/users/:id', ({ params }) => {
const { id } = params
if (id === '999') {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}
return HttpResponse.json({ id, name: 'Alice', email: 'alice@example.com' })
}),
// POST — reading request body
http.post('/api/users', async ({ request }) => {
const body = await request.json() as { name: string; email: string }
return HttpResponse.json(
{
id: String(Date.now()),
...body,
createdAt: new Date().toISOString(),
},
{ status: 201 }
)
}),
// DELETE
http.delete('/api/users/:id', () => {
return new HttpResponse(null, { status: 204 })
}),
]
Browser Setup
npx msw init public/ --save
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
// src/main.tsx
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') return
const { worker } = await import('./mocks/browser')
return worker.start({ onUnhandledRequest: 'warn' })
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
})
Node.js (Test Environment) Setup
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from '../mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Practical Test Examples
Basic Data Loading Test
// src/components/UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserList } from './UserList'
describe('UserList', () => {
it('fetches and displays user list', async () => {
render(<UserList />)
expect(screen.getByRole('status')).toHaveTextContent('Loading...')
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
// Users from the default handler appear
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
})
Overriding Handlers Per Test
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
describe('UserList error cases', () => {
it('shows error message on API failure', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load data')
})
})
it('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()
})
})
})
// server.resetHandlers() runs in afterEach, restoring default handlers
Verifying Request Payloads
describe('CreateUser form', () => {
it('calls API with correct data on form submit', async () => {
const user = userEvent.setup()
const requestSpy = vi.fn()
server.use(
http.post('/api/users', async ({ request }) => {
const body = await request.json()
requestSpy(body)
return HttpResponse.json({ id: '123', ...(body as object) }, { status: 201 })
})
)
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' }))
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith({
name: 'Charlie',
email: 'charlie@example.com',
})
})
})
})
Simulating Delays and Network Errors
import { http, HttpResponse, delay } from 'msw'
export const handlers = [
// Delayed response
http.get('/api/slow-data', async () => {
await delay(2000)
return HttpResponse.json({ data: 'slow response' })
}),
// Network error (connection failure)
http.get('/api/unreachable', () => {
return HttpResponse.error()
}),
// Realistic network delay simulation
http.get('/api/realistic', async () => {
await delay('real')
return HttpResponse.json({ data: 'ok' })
}),
]
Testing loading states:
it('shows skeleton UI while loading', async () => {
server.use(
http.get('/api/users', async () => {
await delay(500)
return HttpResponse.json([])
})
)
render(<UserList />)
expect(screen.getAllByTestId('skeleton-item')).toHaveLength(3)
await waitFor(() => {
expect(screen.queryByTestId('skeleton-item')).not.toBeInTheDocument()
})
})
GraphQL Handlers
import { graphql, HttpResponse } from 'msw'
export const handlers = [
graphql.query('GetUser', ({ variables }) => {
return HttpResponse.json({
data: {
user: { id: variables.id, name: 'Alice', email: 'alice@example.com' },
},
})
}),
graphql.mutation('UpdateUser', ({ variables }) => {
return HttpResponse.json({
data: {
updateUser: {
...variables.input,
updatedAt: new Date().toISOString(),
},
},
})
}),
graphql.query('GetDeletedUser', () => {
return HttpResponse.json({
errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
})
}),
]
Storybook Integration
MSW + Storybook lets you visualize every component state without a real API.
npm install msw-storybook-addon --save-dev
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon'
initialize()
export const loaders = [mswLoader]
// components/UserProfile.stories.tsx
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', () => {
return HttpResponse.json({
id: '1',
name: 'Alice',
email: 'alice@example.com',
bio: 'Frontend developer',
})
}),
],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', async () => {
await new Promise(() => {}) // never resolves
}),
],
},
},
}
export const NotFound: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', () => {
return HttpResponse.json({ error: 'User not found' }, { status: 404 })
}),
],
},
},
}
Organizing Handlers at Scale
// src/mocks/handlers/users.ts
export const userHandlers = [
http.get('/api/users', () => { /* ... */ }),
http.get('/api/users/:id', () => { /* ... */ }),
http.post('/api/users', async () => { /* ... */ }),
]
// src/mocks/handlers/posts.ts
export const postHandlers = [
http.get('/api/posts', () => { /* ... */ }),
]
// src/mocks/handlers/index.ts
import { userHandlers } from './users'
import { postHandlers } from './posts'
import { authHandlers } from './auth'
export const handlers = [
...authHandlers,
...userHandlers,
...postHandlers,
]
@mswjs/data: In-Memory Database
For complex CRUD scenarios, use @mswjs/data for a stateful in-memory DB:
import { factory, primaryKey } from '@mswjs/data'
export const db = factory({
user: {
id: primaryKey(() => String(Date.now())),
name: String,
email: String,
role: String,
},
})
// Seed data
db.user.create({ name: 'Alice', email: 'alice@example.com', role: 'admin' })
http.post('/api/users', async ({ request }) => {
const data = await request.json() as { name: string; email: string }
const user = db.user.create(data)
return HttpResponse.json(user, { status: 201 })
}),
http.delete('/api/users/:id', ({ params }) => {
db.user.delete({ where: { id: { equals: params.id as string } } })
return new HttpResponse(null, { status: 204 })
}),
This lets you test real stateful CRUD behavior across tests.
Common Mistakes
1. Reinitializing server in beforeEach
// Wrong: restarts server on every test (slow)
beforeEach(() => server.listen())
afterEach(() => server.close())
// Correct
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
2. URL matching gotchas
// Relative paths work
http.get('/api/users', handler)
// Absolute paths work too
http.get('http://localhost:3000/api/users', handler)
// Wildcards
http.get('/api/users/*', handler) // matches /api/users/1, /api/users/2
3. Response format matching
// JSON response — auto-adds Content-Type: application/json
return HttpResponse.json(data)
// Text response
return new HttpResponse('plain text', {
headers: { 'Content-Type': 'text/plain' },
})
Conclusion: Why MSW Is the Standard
MSW has become the de facto standard for API mocking because:
- HTTP client agnostic: Same handlers work with fetch, axios, or anything else
- Real network behavior: Request headers, params, and body work like the real thing
- Reusable: Same handlers in dev server, tests, and Storybook
- Type safe: Natural TypeScript integration
- Maintainable: Handlers organized by domain
You can fully develop frontend features without a real API server. Because dev and test environments share the same handlers, the "works in tests but not in dev" problem disappears.
If you have time to invest in your testing infrastructure, MSW is worth adopting today.