When building frontend applications that communicate with APIs, developers face a testing dilemma: either stand up a real backend (slow, complex, unreliable in CI) or mock the HTTP client (tightly coupled to implementation details). If you mock fetch or axios directly, your tests break whenever you refactor how the application makes requests — even if the behavior hasn’t changed. Worse, the application under test never actually makes network calls, so you can’t catch bugs in request construction, header handling, or error response parsing.
Mock Service Worker (MSW) solves this by intercepting requests at the network level. In the browser, it installs a Service Worker that intercepts outgoing requests before they leave the browser. In Node.js (for unit tests), it patches the native http/https modules. Either way, the application code makes real fetch() calls that go through the full request lifecycle — constructing headers, serializing bodies, parsing responses — but MSW intercepts them and returns mock responses. The application doesn’t know the API is mocked; it behaves identically to production.
This topic covers MSW’s architecture, the request handler API, how to use MSW in unit tests (Vitest/Jest), integration tests, and Storybook stories, and patterns for organizing mock definitions at scale. A typical frontend uses MSW extensively for component testing and Storybook development.
MSW operates at the network boundary, not the application boundary. Traditional mocking replaces fetch or the HTTP client with a stub — but that means the application’s request construction code (setting headers, building URLs, serializing query parameters) is never exercised. MSW lets the application construct and send real requests, intercepting them only at the point where they would leave the JavaScript runtime. This catches real bugs: a misspelled header, a wrong Content-Type, a query parameter that should be URL-encoded but isn’t.
MSW handlers are functions that match incoming requests by method and URL pattern, then return a mock response. The http.get('/api/v1/projects', ({ request }) => { ... }) syntax reads like an Express route definition, making handlers intuitive for anyone with backend experience. Handlers can inspect request headers, parse request bodies, access URL parameters, and return different responses based on the request content — enabling stateful mocking where a POST handler adds data that subsequent GET handlers return.
The same handler definitions work in both browser and Node.js environments. In the browser, setupWorker() registers a Service Worker; in Node.js, setupServer() patches the HTTP modules. Your mock definitions are written once and shared across Storybook (browser), unit tests (Node.js/Vitest), and integration tests. This eliminates the common problem of maintaining separate mock configurations for different test environments that inevitably drift apart.
MSW supports runtime handler overrides: you define baseline “happy path” handlers that most tests use, then individual tests can temporarily add or replace handlers using server.use() for specific scenarios (error states, empty responses, slow responses). After each test, server.resetHandlers() restores the baseline. This pattern keeps test setup minimal — most tests need zero mock configuration because the baseline handlers cover the common case.
MSW 2.0 integrates deeply with TypeScript. Response bodies are typed against your API schema, so a handler that returns { projets: [] } (typo) instead of { projects: [] } is caught at compile time. When combined with generated API types from OpenAPI or GraphQL schemas, MSW handlers become a type-checked contract between frontend and backend — if the API response shape changes, your mocks fail to compile before your tests even run.
MSW’s architecture differs between browser and Node.js environments, but the handler API is identical in both. Handlers are the shared abstraction; the interception mechanism is an implementation detail.
// mocks/handlers.ts — shared between browser and Node.js
import { http, HttpResponse } from 'msw';
// Type definitions matching the API contract.
interface Project {
id: string;
name: string;
status: 'active' | 'archived';
createdAt: string;
}
// In-memory state for stateful mocking.
const projects: Project[] = [
{ id: '1', name: 'Acme Production', status: 'active', createdAt: '2026-01-15T00:00:00Z' },
{ id: '2', name: 'Acme Staging', status: 'active', createdAt: '2026-02-01T00:00:00Z' },
{ id: '3', name: 'Legacy API', status: 'archived', createdAt: '2025-06-01T00:00:00Z' },
];
export const handlers = [
// GET /api/v1/projects — list all projects
http.get('/api/v1/projects', ({ request }) => {
const url = new URL(request.url);
const status = url.searchParams.get('status');
const filtered = status
? projects.filter((p) => p.status === status)
: projects;
return HttpResponse.json({ projects: filtered });
}),
// GET /api/v1/projects/:id — get a single project
http.get('/api/v1/projects/:id', ({ params }) => {
const project = projects.find((p) => p.id === params.id);
if (!project) {
return HttpResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
return HttpResponse.json({ project });
}),
// POST /api/v1/projects — create a new project
http.post('/api/v1/projects', async ({ request }) => {
const body = (await request.json()) as { name: string };
const newProject: Project = {
id: String(projects.length + 1),
name: body.name,
status: 'active',
createdAt: new Date().toISOString(),
};
projects.push(newProject);
return HttpResponse.json({ project: newProject }, { status: 201 });
}),
// DELETE /api/v1/projects/:id
http.delete('/api/v1/projects/:id', ({ params }) => {
const index = projects.findIndex((p) => p.id === params.id);
if (index === -1) {
return HttpResponse.json({ error: 'Not found' }, { status: 404 });
}
projects.splice(index, 1);
return new HttpResponse(null, { status: 204 });
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts (or jest.setup.ts)
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
// Start the mock server before all tests.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset any per-test handler overrides after each test.
afterEach(() => server.resetHandlers());
// Clean up after all tests are done.
afterAll(() => server.close());
// src/components/ProjectList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/server';
import { ProjectList } from './ProjectList';
test('renders project list from API', async () => {
// Uses the default handlers — no mock setup needed.
render(<ProjectList />);
// Wait for the API data to render.
await waitFor(() => {
expect(screen.getByText('Acme Production')).toBeInTheDocument();
expect(screen.getByText('Acme Staging')).toBeInTheDocument();
});
});
test('shows error message when API returns 500', async () => {
// Override the default handler for this specific test.
server.use(
http.get('/api/v1/projects', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
})
);
render(<ProjectList />);
await waitFor(() => {
expect(screen.getByText('Failed to load projects')).toBeInTheDocument();
});
});
test('shows empty state when no projects exist', async () => {
server.use(
http.get('/api/v1/projects', () => {
return HttpResponse.json({ projects: [] });
})
);
render(<ProjectList />);
await waitFor(() => {
expect(screen.getByText('No projects yet')).toBeInTheDocument();
});
});
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../mocks/handlers';
// Initialize MSW in the browser with the Service Worker.
initialize();
export default {
loaders: [mswLoader],
parameters: {
msw: {
handlers,
},
},
};
// Individual stories can override handlers:
// ProjectList.stories.tsx
export const ErrorState = {
parameters: {
msw: {
handlers: [
http.get('/api/v1/projects', () => {
return HttpResponse.json({ error: 'fail' }, { status: 500 });
}),
],
},
},
};
A typical frontend uses MSW as the standard mocking layer across its entire testing and development workflow. Component tests written with Vitest and React Testing Library use MSW via setupServer to mock all API calls. The same handler definitions drive Storybook stories, allowing designers and product managers to interact with realistic UI states (loading, error, empty, populated) without running the backend.
During development, engineers run the frontend with MSW’s browser integration to develop features before the corresponding API endpoints are built. The handler definitions act as an executable API specification — the frontend team implements against the mock, while the backend team implements against the same type definitions. When the real API is ready, MSW is removed and the frontend works with zero changes because it was making real fetch calls the entire time.
For Storybook, MSW enables every story to represent a specific API scenario. The “Dashboard” story shows the normal state; the “Dashboard — Slow Network” story adds a 3-second delay to all handlers; the “Dashboard — Partial Failure” story returns a 500 for the notifications endpoint while the projects endpoint succeeds. This makes Storybook a comprehensive catalog of UI states that would be difficult to reproduce manually.
Instead of duplicating handler definitions, create factory functions that generate handlers for common patterns. A withError(endpoint, status) factory produces an error handler for any endpoint, while withDelay(handler, ms) wraps any handler with a network delay.
// mocks/factories.ts
import { http, HttpResponse, delay } from 'msw';
export function withNetworkError(method: 'get' | 'post', path: string) {
return http[method](path, () => {
return HttpResponse.error(); // Simulates a network failure.
});
}
export function withDelay<T>(handler: T, ms: number) {
// Wrap the handler's resolver to add a delay.
return http.get('/api/v1/projects', async (info) => {
await delay(ms);
return HttpResponse.json({ projects: [] });
});
}
export function withPaginatedResponse(
path: string,
allItems: unknown[],
pageSize = 20
) {
return http.get(path, ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') ?? '1');
const start = (page - 1) * pageSize;
const items = allItems.slice(start, start + pageSize);
return HttpResponse.json({
items,
total: allItems.length,
page,
pageSize,
hasMore: start + pageSize < allItems.length,
});
});
}
MSW 2.0 supports request observation with server.events. After performing an action in your test, you can verify that the correct API call was made with the right parameters — without coupling your test to how the application makes the call.
test('sends correct payload when creating a project', async () => {
const requestLog: Request[] = [];
server.events.on('request:start', ({ request }) => {
if (request.url.includes('/api/v1/projects') && request.method === 'POST') {
requestLog.push(request.clone());
}
});
render(<CreateProjectForm />);
await userEvent.type(screen.getByLabelText('Name'), 'New Project');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(requestLog).toHaveLength(1);
const body = await requestLog[0].json();
expect(body).toEqual({ name: 'New Project' });
});
By default, MSW passes through requests that don’t match any handler. In tests, this means an unhandled API call silently reaches the (non-existent) real server and fails, producing confusing errors. Always configure onUnhandledRequest: 'error' so that unmocked API calls fail loudly. This surfaces missing handlers immediately rather than producing cryptic “network error” failures.
If handlers modify in-memory state (like the projects array above), that state persists across tests within the same module. Test A creates a project, test B unexpectedly sees four projects instead of three. Reset mutable state in beforeEach or use factory functions that create fresh state for each test invocation. Alternatively, keep handlers stateless — return fixed responses rather than maintaining a mutable data store.
When using MSW in the browser, the Service Worker file (mockServiceWorker.js) can be cached by the browser. After updating MSW, the old worker might still intercept requests with stale logic. Clear the Service Worker registration and cache when updating MSW versions, and configure cache-busting headers for the worker file in development.
It’s tempting to mock every API call in every test, but over-mocking creates tests that pass even when the real API contract changes. Use MSW for unit and component tests where real API calls are impractical, but complement with integration tests (Playwright) that hit the real backend. MSW tests verify component behavior; integration tests verify system behavior.