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.ts is 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 via server.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() in afterEach removes 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) and URL query parsing in handlers instead of one handler per exact URL; fewer handlers, broader coverage.
- For paginated endpoints, drive the response from
searchParams so 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 (setupFilesAfterEach is Vitest; Jest uses setupFilesAfterEach? use setupFilesAfterEach carefully) - 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-crafting Response objects: 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() in afterEach, 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.ts and 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.
msw is in package.json, mockServiceWorker.js is in public/, or setupServer/setupWorker appears 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.
1---2name: msw-api-mocking3description: 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.4license: MIT5---67# MSW API Mocking89This 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.1011## Core Principles12131. **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.142. **One `handlers.ts` is 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.153. **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 via `server.use(...)`, which prepends a one-off override.164. **`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.175. **Reset handlers after every test.** `server.resetHandlers()` in `afterEach` removes per-test overrides; without it, test order starts to matter and the suite rots.186. **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.1920## Setup2122```bash23npm install --save-dev msw24# Browser usage only: place the worker script in your static dir25npx msw init public/ --save26```2728### Shared handlers2930```ts31// src/mocks/handlers.ts32import { http, HttpResponse, delay } from 'msw';3334export interface User {35 id: string;36 name: string;37 role: 'admin' | 'member';38}3940export const handlers = [41 http.get('https://api.example.com/users/:id', ({ params }) => {42 return HttpResponse.json<User>({43 id: String(params.id),44 name: 'Ada Lovelace',45 role: 'admin',46 });47 }),4849 http.get('https://api.example.com/orders', ({ request }) => {50 const url = new URL(request.url);51 const page = Number(url.searchParams.get('page') ?? '1');52 return HttpResponse.json({53 items: [{ id: 'ord_1', total: 4999 }],54 page,55 totalPages: 3,56 });57 }),5859 http.post('https://api.example.com/orders', async ({ request }) => {60 const body = (await request.json()) as { sku?: string; qty?: number };61 if (!body.sku) {62 return HttpResponse.json({ error: 'sku is required' }, { status: 422 });63 }64 await delay(50); // simulate realistic latency65 return HttpResponse.json({ orderId: 'ord_2', ...body }, { status: 201 });66 }),67];68```6970## Patterns7172### 1. Node test setup (Vitest or Jest)7374```ts75// src/mocks/server.ts76import { setupServer } from 'msw/node';77import { handlers } from './handlers';7879export const server = setupServer(...handlers);80```8182```ts83// vitest.setup.ts (register via test.setupFiles in vitest.config.ts)84import { beforeAll, afterEach, afterAll } from 'vitest';85import { server } from './src/mocks/server';8687beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));88afterEach(() => server.resetHandlers());89afterAll(() => server.close());90```9192### 2. Testing a component, then overriding for the failure case9394```tsx95// src/components/UserProfile.test.tsx96import { render, screen } from '@testing-library/react';97import { http, HttpResponse } from 'msw';98import { server } from '../mocks/server';99import { UserProfile } from './UserProfile';100101it('renders the user fetched from the API', async () => {102 render(<UserProfile id="42" />);103 expect(await screen.findByRole('heading', { name: 'Ada Lovelace' })).toBeInTheDocument();104});105106it('shows an error banner when the API is down', async () => {107 // One-off override; resetHandlers() in afterEach removes it108 server.use(109 http.get('https://api.example.com/users/:id', () =>110 HttpResponse.json({ message: 'internal error' }, { status: 500 }),111 ),112 );113114 render(<UserProfile id="42" />);115 expect(await screen.findByRole('alert')).toHaveTextContent('Could not load profile');116});117118it('handles a network-level failure distinctly from a 500', async () => {119 server.use(120 http.get('https://api.example.com/users/:id', () => HttpResponse.error()),121 );122123 render(<UserProfile id="42" />);124 expect(await screen.findByRole('alert')).toHaveTextContent('Check your connection');125});126```127128### 3. Asserting on the request your code sent129130```ts131// src/api/orders.test.ts132import { http, HttpResponse } from 'msw';133import { server } from '../mocks/server';134import { createOrder } from './orders';135136it('sends the auth header and JSON body the API expects', async () => {137 let captured: { auth: string | null; body: unknown } | undefined;138139 server.use(140 http.post('https://api.example.com/orders', async ({ request }) => {141 captured = {142 auth: request.headers.get('authorization'),143 body: await request.json(),144 };145 return HttpResponse.json({ orderId: 'ord_9' }, { status: 201 });146 }),147 );148149 await createOrder({ sku: 'SKU-1', qty: 2 }, { token: 'jwt-abc' });150151 expect(captured?.auth).toBe('Bearer jwt-abc');152 expect(captured?.body).toEqual({ sku: 'SKU-1', qty: 2 });153});154```155156### 4. GraphQL operations157158```ts159// src/mocks/graphql-handlers.ts160import { graphql, HttpResponse } from 'msw';161162export const gqlHandlers = [163 graphql.query('GetCart', ({ variables }) => {164 return HttpResponse.json({165 data: {166 cart: { id: variables.cartId, items: [{ sku: 'SKU-1', qty: 1 }] },167 },168 });169 }),170171 graphql.mutation('AddToCart', () => {172 return HttpResponse.json({173 errors: [{ message: 'Out of stock', extensions: { code: 'OUT_OF_STOCK' } }],174 });175 }),176];177```178179### 5. Browser worker for dev and Storybook180181```ts182// src/mocks/browser.ts183import { setupWorker } from 'msw/browser';184import { handlers } from './handlers';185186export const worker = setupWorker(...handlers);187```188189```ts190// src/main.tsx -- enable mocking only in development191async function enableMocking(): Promise<void> {192 if (!import.meta.env.DEV) return;193 const { worker } = await import('./mocks/browser');194 await worker.start({ onUnhandledRequest: 'bypass' });195}196197enableMocking().then(() => {198 createRoot(document.getElementById('root')!).render(<App />);199});200```201202## Best Practices203204- Type your response bodies (`HttpResponse.json<User>(...)`) so mock drift becomes a compile error when the app's types change.205- Use `delay()` in handlers that back loading-state tests; a 0ms response can resolve before React renders the spinner you are asserting on.206- Keep path params (`:id`) and `URL` query parsing in handlers instead of one handler per exact URL; fewer handlers, broader coverage.207- For paginated endpoints, drive the response from `searchParams` so the same handler serves page 1 and page 7 tests.208- In Jest (not Vitest), polyfill as needed per MSW docs and register the setup file via `setupFilesAfterEach`/`setupFilesAfterEach`-equivalent (`setupFilesAfterEach` is Vitest; Jest uses `setupFilesAfterEach`? use `setupFilesAfterEach` carefully) - concretely: `setupFiles: ['<rootDir>/jest.setup.ts']` with the same listen/reset/close trio.209- 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`.210211## Anti-Patterns212213- Stubbing `global.fetch = vi.fn()` and hand-crafting `Response` objects: brittle, skips URL matching, and dies the day you switch to axios.214- `onUnhandledRequest: 'bypass'` in tests: unmocked calls silently reach real services, making tests slow, flaky, and occasionally destructive.215- Defining error-case handlers globally so every test starts from a broken API and "fixes" it with overrides - invert it.216- Forgetting `server.resetHandlers()` in `afterEach`, then debugging why a 500 override leaks into the next twelve tests.217- Mocking your own server's routes in end-to-end tests; MSW is for unit/integration layers, E2E should hit a real (containerized) backend.218- Duplicate handler arrays per test file drifting apart; share `handlers.ts` and override locally.219220## When to Trigger This Skill221222- Tests stub `fetch`, `axios`, or API client modules by hand, or a component test suite needs network responses.223- `msw` is in package.json, `mockServiceWorker.js` is in `public/`, or `setupServer`/`setupWorker` appears in the codebase.224- 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".225- A GraphQL client (Apollo, urql, graphql-request) needs operation-level mocks by query name.226- Frontend development is blocked on an unfinished backend and needs a realistic mock layer that later doubles as test fixtures.