by thetestingacademy
Mock Service Worker for seamless API mocking in browser and Node.js environments
npx @qaskills/cli add msw-mockingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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.
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.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.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.server.resetHandlers() in afterEach removes per-test overrides; without it, test order starts to matter and the suite rots.npm install --save-dev msw
# Browser usage only: place the worker script in your static dir
npx msw init public/ --save
// 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 });
}),
];
// 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());
// 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');
});
// 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 });
});
// 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' } }],
});
}),
];
// 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 />);
});
HttpResponse.json<User>(...)) so mock drift becomes a compile error when the app's types change.delay() in handlers that back loading-state tests; a 0ms response can resolve before React renders the spinner you are asserting on.:id) and URL query parsing in handlers instead of one handler per exact URL; fewer handlers, broader coverage.searchParams so the same handler serves page 1 and page 7 tests.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.handlers.ts.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.server.resetHandlers() in afterEach, then debugging why a 500 override leaks into the next twelve tests.handlers.ts and override locally.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.- name: Install QA Skills
run: npx @qaskills/cli add msw-mocking10 of 29 agents supported
Build AI agents that write, run, and fix tests. Playwright, LLM evals, and CI in one live cohort.
Use code AITESTER at checkout