MSW for API Mocking
The Problem with Mocking Fetch
You've seen this pattern a hundred times:
vi.mock('../api/users', () => ({
getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}))
It works. Your test passes. You ship it. Then someone renames getUser to fetchUser, and the test still passes because it's mocking a module that no longer exists in the real code. The test is green, but it's testing nothing.
This is implementation coupling — your test knows how your component fetches data internally. Change the implementation, and the test either breaks for the wrong reason or passes when it shouldn't.
Here's the thing most people miss: the component doesn't care how data arrives. It sends a network request and uses the response. Your tests should work the same way — intercept at the network level, not at the module level.
That's exactly what MSW does.
What MSW Actually Does
MSW (Mock Service Worker) intercepts outgoing network requests at the lowest level your code touches — the fetch or XMLHttpRequest API. Your application code runs exactly as it does in production. No modules are replaced. No functions are stubbed. The only difference is that the network response comes from your handler instead of a real server.
Your component Production With MSW
| | |
fetch('/api/user') → Real server → MSW handler
| | |
Gets response ← Real response ← Mocked response
Your component calls fetch('/api/user') the same way in both cases. It has no idea MSW exists. This means:
- Rename your API wrapper? Tests still work — they don't know about your wrapper.
- Switch from Axios to fetch? Tests still work — MSW intercepts both.
- Add a caching layer? Tests still work — the network request still happens (or doesn't, which is also testable).
Setting Up MSW with Vitest
MSW uses a concept of handlers (what to intercept) and a server (what runs in Node.js tests). Here's the setup from scratch.
Install MSW
pnpm add -D msw
Define Your Handlers
Handlers describe what requests to intercept and what to respond with. Think of them as a tiny API server you control completely.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/user/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice',
email: 'alice@example.com',
})
}),
http.get('/api/posts', ({ request }) => {
const url = new URL(request.url)
const limit = Number(url.searchParams.get('limit')) || 10
return HttpResponse.json(
Array.from({ length: limit }, (_, i) => ({
id: i + 1,
title: `Post ${i + 1}`,
}))
)
}),
http.post('/api/posts', async ({ request }) => {
const body = await request.json()
return HttpResponse.json(
{ id: crypto.randomUUID(), ...body },
{ status: 201 }
)
}),
]
Notice a few things:
http.get,http.post— matches the HTTP method. MSW also hashttp.put,http.patch,http.delete.- Path params —
:idin the path becomesparams.idin the resolver. Works just like Express routes. - Query params — read from the standard
Requestobject. No special API to learn. - Request body — use
await request.json()for JSON,await request.text()for text,await request.formData()for forms. It's the standardRequestAPI. HttpResponse.json()— creates a proper JSON response. You can also useHttpResponse.text(),HttpResponse.xml(), ornew HttpResponse()for full control.
Create the Server
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
setupServer takes your handlers and creates a server that intercepts requests in Node.js. It doesn't start a real HTTP server — it patches the global fetch and XMLHttpRequest.
Wire It into Vitest
// vitest.setup.ts
import { server } from './src/mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
And in your Vitest config:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
environment: 'jsdom',
},
})
That's it. Every test in your project now has MSW running. Requests to /api/user/:id get intercepted automatically. No per-test setup needed for the happy path.
- 1Define happy-path handlers in a shared handlers file — these run for every test automatically
- 2Call server.resetHandlers() in afterEach to clear per-test overrides and prevent test pollution
- 3Call server.listen() before all tests and server.close() after all tests to properly set up and tear down the interception
- 4Use the standard Request API (request.json(), request.url, request.headers) — MSW does not invent its own API for reading requests
- 5Path parameters use Express-style :param syntax and are available via params in the resolver
Response Resolvers in Depth
The function you pass to http.get() or http.post() is called a response resolver. It receives a context object with everything about the incoming request and returns a response.
http.get('/api/resource', ({ request, params, cookies }) => {
// request — the full Request object (url, headers, body, method)
// params — path parameters (from :param segments)
// cookies — parsed cookies from the request
return HttpResponse.json({ data: 'value' })
})
Accessing Different Parts of the Request
// Path parameters
http.get('/api/users/:userId/posts/:postId', ({ params }) => {
const { userId, postId } = params
return HttpResponse.json({ userId, postId })
})
// Query parameters
http.get('/api/search', ({ request }) => {
const url = new URL(request.url)
const query = url.searchParams.get('q')
const page = Number(url.searchParams.get('page')) || 1
return HttpResponse.json({ query, page, results: [] })
})
// Request headers
http.get('/api/protected', ({ request }) => {
const authHeader = request.headers.get('Authorization')
if (!authHeader) {
return new HttpResponse(null, { status: 401 })
}
return HttpResponse.json({ data: 'secret' })
})
// Request body (POST/PUT/PATCH)
http.post('/api/login', async ({ request }) => {
const { email, password } = await request.json()
if (email === 'admin@test.com') {
return HttpResponse.json({ token: 'abc-123' })
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
})
Dynamic Handlers Per Test
Your shared handlers define the happy path — the "everything works" scenario. But individual tests often need specific scenarios: error responses, empty data, specific edge cases.
Use server.use() to add temporary handlers that override the defaults for a single test:
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './UserProfile'
it('shows user info on success', async () => {
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('Alice')).toBeVisible()
})
})
it('shows error message when API fails', async () => {
server.use(
http.get('/api/user/:id', () => {
return new HttpResponse(null, { status: 500 })
})
)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Failed to load user'
)
})
})
it('shows empty state when user has no posts', async () => {
server.use(
http.get('/api/user/:id/posts', () => {
return HttpResponse.json([])
})
)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('No posts yet')).toBeVisible()
})
})
The critical detail: server.use() prepends handlers. MSW matches handlers top-down and stops at the first match. So your per-test handler takes priority over the default one. After the test, server.resetHandlers() (from your afterEach) removes these overrides, and the next test starts clean.
This pattern gives you the best of both worlds:
- Default handlers = happy path shared across all tests (no repetition)
- Per-test overrides = specific scenarios when you need them
Error Scenarios
Testing error handling is where MSW really earns its keep. Here are the patterns you'll use constantly:
HTTP Error Statuses
server.use(
http.get('/api/user/:id', () => {
return new HttpResponse(null, { status: 404 })
})
)
server.use(
http.post('/api/checkout', () => {
return HttpResponse.json(
{ error: 'Payment declined' },
{ status: 402 }
)
})
)
server.use(
http.get('/api/data', () => {
return HttpResponse.json(
{ message: 'Rate limit exceeded' },
{ status: 429, headers: { 'Retry-After': '60' } }
)
})
)
Network Errors
Sometimes the request itself fails — no response at all. MSW can simulate that too:
server.use(
http.get('/api/user/:id', () => {
return HttpResponse.error()
})
)
HttpResponse.error() creates a network error — the fetch promise rejects, just like when the user is offline. This is different from a 500 status (where fetch resolves with response.ok === false).
Validation Errors with Structured Bodies
server.use(
http.post('/api/register', () => {
return HttpResponse.json(
{
errors: {
email: 'Email already taken',
password: 'Must be at least 8 characters',
},
},
{ status: 422 }
)
})
)
Delay Simulation
Real APIs don't respond instantly. Testing loading states, timeouts, and race conditions requires simulating network latency.
import { http, HttpResponse, delay } from 'msw'
// Fixed delay
http.get('/api/slow-endpoint', async () => {
await delay(2000)
return HttpResponse.json({ data: 'finally' })
})
// Realistic random delay (between a few hundred ms)
http.get('/api/endpoint', async () => {
await delay()
return HttpResponse.json({ data: 'value' })
})
// Infinite delay — request never resolves (great for testing loading states)
http.get('/api/forever', async () => {
await delay('infinite')
return HttpResponse.json({ data: 'never sent' })
})
The infinite delay is surprisingly useful. Need to assert that a loading spinner appears? Use an infinite delay so the component stays in the loading state forever — no race conditions in your test:
it('shows a loading spinner while fetching', async () => {
server.use(
http.get('/api/user/:id', async () => {
await delay('infinite')
return HttpResponse.json({})
})
)
render(<UserProfile userId="1" />)
expect(screen.getByRole('progressbar')).toBeVisible()
})
Request Assertions
Sometimes you need to verify that your component sent the right request — the right URL, the right headers, the right body. MSW lets you capture requests for assertions:
it('sends the correct payload when creating a post', async () => {
let capturedBody: Record<string, unknown> | null = null
server.use(
http.post('/api/posts', async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json({ id: '1' }, { status: 201 })
})
)
render(<CreatePost />)
await userEvent.type(screen.getByLabelText('Title'), 'My Post')
await userEvent.type(screen.getByLabelText('Body'), 'Content here')
await userEvent.click(screen.getByRole('button', { name: 'Publish' }))
await waitFor(() => {
expect(capturedBody).toEqual({
title: 'My Post',
body: 'Content here',
})
})
})
But be thoughtful here. Your primary assertion should be the UI outcome ("success message appeared"), not the request details. Asserting the payload is useful for forms and mutations where the exact data matters, but don't assert every header and URL parameter — that's implementation coupling sneaking back in.
Passthrough and Bypass
Not every request should be intercepted. MSW gives you two escape hatches:
Passthrough
Let a specific request through to the real server:
import { http, passthrough } from 'msw'
http.get('/api/analytics', () => {
return passthrough()
})
Conditional Passthrough
http.get('/api/resource', ({ request }) => {
if (request.headers.has('x-real-request')) {
return passthrough()
}
return HttpResponse.json({ mocked: true })
})
Bypass
Make a real request from inside a handler (useful for response patching):
import { http, bypass, HttpResponse } from 'msw'
http.get('/api/user', async ({ request }) => {
const realResponse = await fetch(bypass(request))
const realUser = await realResponse.json()
return HttpResponse.json({
...realUser,
name: 'Overridden Name',
})
})
bypass wraps the request so MSW doesn't intercept it again. Without it, you'd create an infinite loop.
MSW in Storybook
MSW isn't just for tests. It's equally powerful in Storybook for previewing components with realistic data — and especially for previewing error and loading states.
Install the addon:
pnpm add -D msw-storybook-addon
Initialize the service worker (MSW uses a service worker in the browser, unlike Node.js where it patches globals):
npx msw init public/ --save
Configure in your Storybook preview:
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon'
initialize()
export default {
loaders: [mswLoader],
}
Then use handlers directly in your stories:
// UserProfile.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { http, HttpResponse, delay } from 'msw'
import { UserProfile } from './UserProfile'
const meta: Meta<typeof UserProfile> = {
component: UserProfile,
}
export default meta
type Story = StoryObj<typeof UserProfile>
export const Default: Story = {
args: { userId: '1' },
parameters: {
msw: {
handlers: [
http.get('/api/user/:id', () => {
return HttpResponse.json({
id: '1',
name: 'Alice',
avatar: 'https://i.pravatar.cc/150',
})
}),
],
},
},
}
export const Loading: Story = {
args: { userId: '1' },
parameters: {
msw: {
handlers: [
http.get('/api/user/:id', async () => {
await delay('infinite')
return HttpResponse.json({})
}),
],
},
},
}
export const Error: Story = {
args: { userId: '1' },
parameters: {
msw: {
handlers: [
http.get('/api/user/:id', () => {
return new HttpResponse(null, { status: 500 })
}),
],
},
},
}
Each story gets its own set of handlers. The Loading story uses an infinite delay so the loading state is permanently visible. The Error story returns a 500 so you can style and verify the error UI.
MSW in Development
You can also run MSW in your development server to work without a backend:
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
// src/main.tsx (or app entry point)
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') return
const { worker } = await import('./mocks/browser')
return worker.start()
}
enableMocking().then(() => {
// Render your app
})
Now your entire frontend runs against mocked APIs during development. The same handlers you use in tests power your dev environment. One source of truth for API behavior.
Structuring Handlers for Large Projects
As your app grows, a single handlers.ts file gets unwieldy. Organize by feature:
src/mocks/
handlers/
auth.ts // login, logout, session
users.ts // user CRUD
posts.ts // posts and comments
index.ts // combines all handlers
server.ts // setupServer
browser.ts // setupWorker
// src/mocks/handlers/auth.ts
import { http, HttpResponse } from 'msw'
export const authHandlers = [
http.post('/api/login', async ({ request }) => {
const { email } = await request.json()
return HttpResponse.json({
user: { id: '1', email },
token: 'mock-jwt-token',
})
}),
http.post('/api/logout', () => {
return new HttpResponse(null, { status: 204 })
}),
]
// src/mocks/handlers/index.ts
import { authHandlers } from './auth'
import { userHandlers } from './users'
import { postHandlers } from './posts'
export const handlers = [
...authHandlers,
...userHandlers,
...postHandlers,
]
This scales cleanly. Each feature team owns their handlers. Adding a new API endpoint means adding a handler in the right file — no merge conflicts on a giant shared file.
TypeScript and MSW
MSW 2.0 has first-class TypeScript support. You can type your path parameters, request bodies, and response bodies:
import { http, HttpResponse } from 'msw'
type UserParams = { userId: string }
type CreateUserBody = { name: string; email: string }
type UserResponse = { id: string; name: string; email: string }
http.get<UserParams, never, UserResponse, '/api/users/:userId'>(
'/api/users/:userId',
({ params }) => {
// params.userId is typed as string
return HttpResponse.json({
id: params.userId,
name: 'Alice',
email: 'alice@example.com',
})
}
)
http.post<never, CreateUserBody, UserResponse>(
'/api/users',
async ({ request }) => {
const body = await request.json()
// body is typed as CreateUserBody
return HttpResponse.json({
id: crypto.randomUUID(),
name: body.name,
email: body.email,
})
}
)
The generic parameters follow the order: PathParams, RequestBody, ResponseBody, RequestPath. You only need to specify the ones you care about.
Common Patterns and Recipes
Testing Authenticated Routes
const authenticatedHandlers = [
http.get('/api/me', ({ request }) => {
const token = request.headers.get('Authorization')
if (!token || !token.startsWith('Bearer ')) {
return HttpResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
return HttpResponse.json({
id: '1',
name: 'Alice',
role: 'admin',
})
}),
]
Testing Paginated Endpoints
const allPosts = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
title: `Post ${i + 1}`,
}))
http.get('/api/posts', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page')) || 1
const perPage = Number(url.searchParams.get('per_page')) || 10
const start = (page - 1) * perPage
const items = allPosts.slice(start, start + perPage)
return HttpResponse.json({
items,
total: allPosts.length,
page,
totalPages: Math.ceil(allPosts.length / perPage),
})
})
Testing Optimistic Updates
it('shows the new comment immediately before server confirms', async () => {
server.use(
http.post('/api/comments', async ({ request }) => {
await delay(1000)
const body = await request.json()
return HttpResponse.json(
{ id: '99', ...body },
{ status: 201 }
)
})
)
render(<CommentSection postId="1" />)
await userEvent.type(screen.getByLabelText('Comment'), 'Great post!')
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
expect(screen.getByText('Great post!')).toBeVisible()
expect(screen.getByText('Sending...')).toBeVisible()
})
One-Time Handlers
Need a handler that only works once — like simulating a retry succeeding on the second attempt?
import { http, HttpResponse } from 'msw'
let callCount = 0
server.use(
http.get('/api/flaky', () => {
callCount++
if (callCount === 1) {
return new HttpResponse(null, { status: 503 })
}
return HttpResponse.json({ data: 'success' })
})
)
Or use the { once: true } option for handlers that should only match once:
server.use(
http.get('/api/resource', () => {
return new HttpResponse(null, { status: 500 })
}, { once: true })
)
After matching once, this handler is removed, and the next request falls through to the default handler.
| What developers do | What they should do |
|---|---|
| Forgetting server.resetHandlers() in afterEach, causing test pollution where per-test overrides leak into other tests Without resetHandlers, a server.use() override in one test persists for all subsequent tests. This causes hard-to-debug failures where tests pass in isolation but fail when run together. | Always call server.resetHandlers() in afterEach — it removes per-test overrides added via server.use() and restores the original handlers |
| Using MSW v1 syntax (ctx.json, ctx.status, rest.get) with MSW 2.0 MSW 2.0 was a complete rewrite. The old rest.get/ctx.json API was removed. The new API uses standard Web APIs (Request, Response) instead of custom abstractions, making it more portable and easier to learn. | Use the MSW 2.0 API: http.get/post with HttpResponse.json() for responses — there is no ctx or rest namespace in v2 |
| Asserting the request URL and headers in every test instead of asserting the UI outcome Over-asserting request details couples your test to implementation. If you refactor the URL from /api/v1/users to /api/v2/users, every test breaks even though the UI behavior is identical. Focus on what the user experiences. | Assert the UI outcome first (what the user sees), and only assert request details when the exact payload matters (like form submissions) |
| Defining all handlers inside individual test files instead of sharing a common set Duplicating handlers across test files violates DRY and makes it painful to update when your API shape changes. A shared handlers file is your single source of truth for default API behavior. | Define happy-path handlers in a shared handlers file, and only use server.use() in individual tests for scenario-specific overrides |
| Using HttpResponse.error() when you mean to return a 500 status code HttpResponse.error() causes the fetch promise to reject (like being offline). A 500 status causes fetch to resolve with response.ok = false. These trigger completely different error handling code paths in your application. | Use new HttpResponse(null, { status: 500 }) for server errors, and HttpResponse.error() only when you want to simulate a network failure where no response is received at all |
When NOT to Use MSW
MSW is the right tool for integration tests where your component makes real fetch calls. But it's not always the answer:
- Pure unit tests — testing a utility function that transforms data? Just call the function. No network involved, no MSW needed.
- E2E tests with Playwright — Playwright has its own
page.route()API for intercepting requests. MSW can work with Playwright via a dedicated package, but Playwright's built-in interception is simpler for E2E. - Testing the API itself — MSW mocks the API. If you're testing your actual API routes (Next.js Route Handlers), you need a real server or a supertest-style approach.
Quick Reference
| Task | MSW API |
|---|---|
| Intercept GET | http.get('/path', resolver) |
| Intercept POST | http.post('/path', resolver) |
| JSON response | HttpResponse.json(data) |
| Error status | new HttpResponse(null, { status: 500 }) |
| Network error | HttpResponse.error() |
| Read path params | ({ params }) => params.id |
| Read query params | new URL(request.url).searchParams |
| Read body | await request.json() |
| Add delay | await delay(ms) or await delay() |
| Infinite delay | await delay('infinite') |
| Per-test override | server.use(handler) |
| Reset overrides | server.resetHandlers() |
| Let request through | return passthrough() |
| One-time handler | http.get('/path', resolver, { once: true }) |