name: MSW API Mocking description: Mock Service Worker v2 patterns - http and graphql request handlers, setupServer for Node test runs, setupWorker for the browser, per-test handler overrides, and strict unhandled-request policies. version: 1.0.0 author: thetestingacademy license: MIT tags: [msw, mocking, api-mocking, service-worker, jest, vitest, react, graphql, network] testingTypes: [unit, integration, api] frameworks: [jest, vitest] languages: [typescript] domains: [web, api] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp]
MSW API Mocking
This skill makes an AI agent mock HTTP and GraphQL APIs at the network level with Mock Service Worker v2: one set of request handlers shared between Vitest/Jest (via setupServer) and the browser (via setupWorker), per-test overrides with server.use, and an onUnhandledRequest: 'error' policy that catches drift. Trigger it when components or services call fetch/axios in tests, when msw appears in package.json, or when the user is stubbing global.fetch by hand and suffering for it.
Core Principles
- Mock the network, not the module.
vi.mock('./api-client')couples tests to an import path and skips serialization, query strings, and status handling. MSW intercepts actual requests, so the entire client stack (interceptors, retries, parsing) stays under test. - One
handlers.tsis the contract. Define happy-path handlers once; tests, Storybook, and local dev all consume the same array. When the real API changes, you update one file and every consumer notices. - Happy path in global handlers, failures per test. The default handlers return realistic success responses. Error cases (
500,422, timeouts) are declared inside the test that needs them viaserver.use(...), which prepends a one-off override. onUnhandledRequest: 'error'always. Any request without a handler should fail the test loudly. Silent passthrough is how a "unit" test ends up hitting production from CI.- Reset handlers after every test.
server.resetHandlers()inafterEachremoves per-test overrides; without it, test order starts to matter and the suite rots. - Respond with realistic shapes and status codes. Use the same field names, casing, pagination envelopes, and error bodies the real API returns; mocks that drift teach your code to handle an API that does not exist.
Setup
npm install --save-dev msw
# Browser usage only: place the worker script in your static dir
npx msw init public/ --save
Shared handlers
// src/mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw';
export interface User {
id: string;
name: string;
role: 'admin' | 'member';
}
export const handlers = [
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json<User>({
id: String(params.id),
name: 'Ada Lovelace',
role: 'admin',
});
}),
http.get('https://api.example.com/orders', ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') ?? '1');
return HttpResponse.json({
items: [{ id: 'ord_1', total: 4999 }],
page,
totalPages: 3,
});
}),
http.post('https://api.example.com/orders', async ({ request }) => {
const body = (await request.json()) as { sku?: string; qty?: number };
if (!body.sku) {
return HttpResponse.json({ error: 'sku is required' }, { status: 422 });
}
await delay(50); // simulate realistic latency
return HttpResponse.json({ orderId: 'ord_2', ...body }, { status: 201 });
}),
];
Patterns
1. Node test setup (Vitest or Jest)
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts (register via test.setupFiles in vitest.config.ts)
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './src/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
2. Testing a component, then overriding for the failure case
// src/components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { UserProfile } from './UserProfile';
it('renders the user fetched from the API', async () => {
render(<UserProfile id="42" />);
expect(await screen.findByRole('heading', { name: 'Ada Lovelace' })).toBeInTheDocument();
});
it('shows an error banner when the API is down', async () => {
// One-off override; resetHandlers() in afterEach removes it
server.use(
http.get('https://api.example.com/users/:id', () =>
HttpResponse.json({ message: 'internal error' }, { status: 500 }),
),
);
render(<UserProfile id="42" />);
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load profile');
});
it('handles a network-level failure distinctly from a 500', async () => {
server.use(
http.get('https://api.example.com/users/:id', () => HttpResponse.error()),
);
render(<UserProfile id="42" />);
expect(await screen.findByRole('alert')).toHaveTextContent('Check your connection');
});
3. Asserting on the request your code sent
// src/api/orders.test.ts
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { createOrder } from './orders';
it('sends the auth header and JSON body the API expects', async () => {
let captured: { auth: string | null; body: unknown } | undefined;
server.use(
http.post('https://api.example.com/orders', async ({ request }) => {
captured = {
auth: request.headers.get('authorization'),
body: await request.json(),
};
return HttpResponse.json({ orderId: 'ord_9' }, { status: 201 });
}),
);
await createOrder({ sku: 'SKU-1', qty: 2 }, { token: 'jwt-abc' });
expect(captured?.auth).toBe('Bearer jwt-abc');
expect(captured?.body).toEqual({ sku: 'SKU-1', qty: 2 });
});
4. GraphQL operations
// src/mocks/graphql-handlers.ts
import { graphql, HttpResponse } from 'msw';
export const gqlHandlers = [
graphql.query('GetCart', ({ variables }) => {
return HttpResponse.json({
data: {
cart: { id: variables.cartId, items: [{ sku: 'SKU-1', qty: 1 }] },
},
});
}),
graphql.mutation('AddToCart', () => {
return HttpResponse.json({
errors: [{ message: 'Out of stock', extensions: { code: 'OUT_OF_STOCK' } }],
});
}),
];
5. Browser worker for dev and Storybook
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// src/main.tsx -- enable mocking only in development
async function enableMocking(): Promise<void> {
if (!import.meta.env.DEV) return;
const { worker } = await import('./mocks/browser');
await worker.start({ onUnhandledRequest: 'bypass' });
}
enableMocking().then(() => {
createRoot(document.getElementById('root')!).render(<App />);
});
Best Practices
- Type your response bodies (
HttpResponse.json<User>(...)) so mock drift becomes a compile error when the app's types change. - Use
delay()in handlers that back loading-state tests; a 0ms response can resolve before React renders the spinner you are asserting on. - Keep path params (
:id) andURLquery parsing in handlers instead of one handler per exact URL; fewer handlers, broader coverage. - For paginated endpoints, drive the response from
searchParamsso the same handler serves page 1 and page 7 tests. - In Jest (not Vitest), polyfill as needed per MSW docs and register the setup file via
setupFilesAfterEach/setupFilesAfterEach-equivalent (setupFilesAfterEachis Vitest; Jest usessetupFilesAfterEach? usesetupFilesAfterEachcarefully) - concretely:setupFiles: ['<rootDir>/jest.setup.ts']with the same listen/reset/close trio. - Co-locate one-off overrides with the test that needs them; if three tests need the same failure handler, promote it to a named export in
handlers.ts.
Anti-Patterns
- Stubbing
global.fetch = vi.fn()and hand-craftingResponseobjects: brittle, skips URL matching, and dies the day you switch to axios. onUnhandledRequest: 'bypass'in tests: unmocked calls silently reach real services, making tests slow, flaky, and occasionally destructive.- Defining error-case handlers globally so every test starts from a broken API and "fixes" it with overrides - invert it.
- Forgetting
server.resetHandlers()inafterEach, then debugging why a 500 override leaks into the next twelve tests. - Mocking your own server's routes in end-to-end tests; MSW is for unit/integration layers, E2E should hit a real (containerized) backend.
- Duplicate handler arrays per test file drifting apart; share
handlers.tsand override locally.
When to Trigger This Skill
- Tests stub
fetch,axios, or API client modules by hand, or a component test suite needs network responses. mswis in package.json,mockServiceWorker.jsis inpublic/, orsetupServer/setupWorkerappears in the codebase.- The user asks to "mock an API", "test loading and error states", "share mocks between tests and Storybook", or "stop tests from hitting the real API".
- A GraphQL client (Apollo, urql, graphql-request) needs operation-level mocks by query name.
- Frontend development is blocked on an unfinished backend and needs a realistic mock layer that later doubles as test fixtures.