Advanced MSW Handler Patterns
This page covers stateful request matching, fault injection, GraphQL mocking, and cross-tool orchestration with MSW — it does not duplicate the initial MSW worker registration and basic route setup covered in the foundation guide.
Prerequisites
- MSW 2.x installed (
msw@^2.0.0) with the Service Worker script generated (npx msw init public/) - TypeScript 5.x project;
strict: trueenabled intsconfig.json - Node.js 20 LTS (for
msw/nodein Jest/Vitest environments) - Familiarity with the request interception pattern — specifically how middleware captures traffic before it reaches the network
-
@faker-js/fakeror equivalent if you are generating dynamic payloads (optional but recommended)
Phase 1 — Core Setup: Handler Registry and Typed Factories
Centralising handlers in a registry prevents route collisions and gives CI pipelines a single entry point for toggling mock behaviour.
1.1 Create a typed state module
// src/mocks/state.ts
export interface SessionState {
authToken: string | null;
cart: Array<{ id: string; qty: number }>;
failureRate: number; // 0–1, injected via env
}
export function createInitialState(): SessionState {
return {
authToken: null,
cart: [],
failureRate: Number(process.env.MOCK_FAULT_RATE ?? 0),
};
}
// Mutable singleton — reset this in beforeEach, never import state directly
let _state = createInitialState();
export const mockState = {
get: () => _state,
reset: () => { _state = createInitialState(); },
set: (patch: Partial<SessionState>) => { _state = { ..._state, ...patch }; },
};
1.2 Build handler factories
// src/mocks/handlers/auth.ts
import { http, HttpResponse, delay } from 'msw';
import { mockState } from '../state';
export function createAuthHandlers() {
return [
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json() as { username: string; password: string };
if (mockState.get().failureRate > Math.random()) {
return new HttpResponse(null, { status: 503, statusText: 'Service Unavailable' });
}
const token = `mock-jwt-${body.username}-${Date.now()}`;
mockState.set({ authToken: token });
await delay(80); // realistic network latency
return HttpResponse.json({ token, expiresIn: 3600 }, { status: 200 });
}),
http.post('/api/auth/logout', () => {
mockState.set({ authToken: null });
return new HttpResponse(null, { status: 204 });
}),
http.get('/api/auth/me', () => {
const { authToken } = mockState.get();
if (!authToken) {
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return HttpResponse.json({ username: authToken.split('-')[2], roles: ['user'] });
}),
];
}
1.3 Assemble the registry
// src/mocks/handlers/index.ts
import { createAuthHandlers } from './auth';
import { createCartHandlers } from './cart';
import { createProductHandlers } from './products';
// Registration order matters — more-specific routes first
export function createHandlers() {
return [
...createAuthHandlers(),
...createCartHandlers(),
...createProductHandlers(),
];
}
// src/mocks/browser.ts (browser entry point)
import { setupWorker } from 'msw/browser';
import { createHandlers } from './handlers';
export const worker = setupWorker(...createHandlers());
// src/mocks/server.ts (Node / Vitest / Jest entry point)
import { setupServer } from 'msw/node';
import { createHandlers } from './handlers';
export const server = setupServer(...createHandlers());
Phase 2 — Configuration: Env Flags, Fault Injection, and Dynamic Routing
2.1 Inject configuration via environment variables
# .env.test
MOCK_FAULT_RATE=0 # deterministic in unit tests
MOCK_SEED=42 # seed random number generators
MOCK_LATENCY_MS=0 # disable artificial delay in fast tests
# .env.development
MOCK_FAULT_RATE=0.05 # 5% faults to exercise retry UI
MOCK_LATENCY_MS=120 # realistic p50 latency
Read these in the state factory (as shown in §1.1) so every handler inherits the same configuration without needing its own process.env calls.
2.2 Multi-tenant and feature-flag routing
// src/mocks/handlers/products.ts
import { http, HttpResponse } from 'msw';
import productsV1 from '../fixtures/products-v1.json';
import productsV2 from '../fixtures/products-v2.json';
export function createProductHandlers() {
return [
http.get('/api/products', ({ request }) => {
const apiVersion = request.headers.get('X-API-Version') ?? '1';
const tenant = new URL(request.url).searchParams.get('tenant') ?? 'default';
const payload = apiVersion === '2' ? productsV2 : productsV1;
return HttpResponse.json({
tenant,
products: payload,
meta: { version: apiVersion, count: payload.length },
});
}),
];
}
2.3 Pagination and cursor-based responses
This aligns with response shaping techniques — the mock must mirror the real API’s envelope shape exactly to avoid hydration mismatches in production.
// src/mocks/handlers/orders.ts
import { http, HttpResponse } from 'msw';
import orders from '../fixtures/orders.json'; // 50-item fixture array
export function createOrderHandlers() {
return [
http.get('/api/orders', ({ request }) => {
const url = new URL(request.url);
const cursor = Number(url.searchParams.get('cursor') ?? 0);
const limit = Number(url.searchParams.get('limit') ?? 10);
const page = orders.slice(cursor, cursor + limit);
const nextCursor = cursor + limit < orders.length ? cursor + limit : null;
return HttpResponse.json({
data: page,
pagination: { cursor, limit, nextCursor, total: orders.length },
});
}),
];
}
Phase 3 — Integration: GraphQL, CI/CD, and Cross-Tool Orchestration
3.1 GraphQL operation mocking
MSW’s graphql namespace matches by operation name rather than URL, which decouples mocks from routing changes and aligns with how abstracting network layers for frontend apps isolates the query layer from transport.
// src/mocks/handlers/graphql.ts
import { graphql, HttpResponse } from 'msw';
export function createGraphQLHandlers() {
return [
graphql.query('GetUserProfile', ({ variables }) => {
const { userId } = variables as { userId: string };
if (userId === 'not-found') {
return HttpResponse.json({
errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
});
}
return HttpResponse.json({
data: {
user: {
id: userId,
name: 'Mock User',
email: `${userId}@example.mock`,
roles: ['viewer'],
},
},
});
}),
graphql.mutation('UpdateUserProfile', ({ variables }) => {
const { input } = variables as { input: { name: string } };
return HttpResponse.json({
data: { updateUser: { id: 'u1', ...input, __typename: 'User' } },
});
}),
];
}
WebSocket subscriptions: MSW does not natively intercept WebSocket frames. For subscription testing, run a ws server on a dedicated port and set REACT_APP_WS_URL=ws://localhost:9001 during tests. Point your GraphQL client’s subscriptionClient at that URL rather than the real backend.
3.2 Vitest / Jest integration
// src/setupTests.ts
import { beforeAll, afterEach, afterAll, beforeEach } from 'vitest';
import { server } from './mocks/server';
import { mockState } from './mocks/state';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers(); // clear test-specific overrides
mockState.reset(); // wipe session state
});
afterAll(() => server.close());
The onUnhandledRequest: 'error' setting enforces that every network call in tests is explicitly handled — it acts as a contract that new API calls cannot silently bypass the mock layer.
3.3 Per-test handler overrides
// src/features/checkout/__tests__/Checkout.test.tsx
import { server } from '../../mocks/server';
import { http, HttpResponse } from 'msw';
it('shows error banner when payment gateway returns 402', async () => {
// Prepend: takes precedence over the registry handler for this test only
server.use(
http.post('/api/checkout/pay', () =>
HttpResponse.json({ error: 'Insufficient funds' }, { status: 402 })
)
);
// ... render and assert
});
3.4 CI/CD pipeline configuration
# .github/workflows/test.yml
jobs:
unit-tests:
runs-on: ubuntu-latest
env:
MOCK_FAULT_RATE: "0"
MOCK_SEED: "42"
MOCK_LATENCY_MS: "0"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm test
resilience-tests:
runs-on: ubuntu-latest
env:
MOCK_FAULT_RATE: "0.25" # 25% faults — validates retry/circuit-breaker logic
MOCK_SEED: "99"
MOCK_LATENCY_MS: "200"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm run test:resilience
3.5 Cross-tool routing with WireMock
When server-side rendering or backend integration tests need mocked responses that MSW’s browser Service Worker cannot reach, route those calls through WireMock standalone configuration instead. The mock lifecycle management pattern applies here: each tool owns a clearly bounded segment of the request graph.
# docker-compose.yml excerpt
services:
wiremock:
image: wiremock/wiremock:3.3.1
ports: ["8080:8080"]
volumes:
- ./wiremock/mappings:/home/wiremock/mappings
command: ["--verbose", "--global-response-templating"]
app:
build: .
environment:
# SSR and Node API calls → WireMock; browser fetch → MSW Service Worker
NEXT_PUBLIC_API_BASE: "http://wiremock:8080"
MOCK_FAULT_RATE: "0.05"
depends_on: [wiremock]
Add an X-Mock-Source response header in both MSW resolvers and WireMock mappings so you can trace which tool handled each request in CI logs:
// In your MSW resolver
return HttpResponse.json(data, {
headers: { 'X-Mock-Source': 'msw' },
});
Verification Steps
- Run
npm testwithMSW_FAULT_RATE=0and confirm no tests fail due to unhandled requests (theonUnhandledRequest: 'error'setting surfaces these immediately) - Run
npm testwithMSW_FAULT_RATE=0.5and confirm resilience tests pass — client error boundaries and retry logic must activate - Open the browser dev-tools Network panel with the dev server running; confirm
[MSW] Mocking enabledappears in the console and that/api/*requests show(from ServiceWorker)in the initiator column - Assert that
server.resetHandlers()inafterEachprevents state leaking between tests: run two tests in sequence where the first usesserver.use()to override a handler; the second test must receive the registry default, not the override - Verify GraphQL handler resolution by running
npm test -- --grep "GetUserProfile"and confirming the mock data shape matches your TypeScript query type
Troubleshooting
TypeError: Failed to fetch — request not intercepted
Cause: The MSW Service Worker is not registered, or worker.start() was never called before the first fetch.
Fix: Ensure worker.start() is called and awaited before rendering. In Next.js, call it in a use client component loaded at the app root:
if (process.env.NEXT_PUBLIC_MOCK_ENABLED === 'true') {
const { worker } = await import('../mocks/browser');
await worker.start({ onUnhandledRequest: 'bypass' });
}
[MSW] Warning: captured a request without a matching request handler
Cause: A route was added to the application but no handler was registered in the mock registry.
Fix: Switch from onUnhandledRequest: 'bypass' to onUnhandledRequest: 'error' in tests so unhandled routes fail fast. Add the missing handler to the registry and re-run.
ReferenceError: SharedArrayBuffer is not defined in Jest with MSW 2
Cause: MSW 2’s Node adapter requires a modern Worker implementation. Some Jest configurations use a legacy environment.
Fix: Add testEnvironment: 'node' (not jsdom) for server-side handler tests, or add the following to jest.config.ts:
globals: {
'ts-jest': { tsconfig: { esModuleInterop: true } },
},
testEnvironmentOptions: {
customExportConditions: [''],
},
Handlers fire in the wrong order — specific routes return catch-all responses
Cause: A catch-all handler (http.get('/api/*', ...)) was registered before the specific handler.
Fix: Always register specific handlers before wildcard handlers. When using server.use() in tests, the prepended handler takes precedence automatically. Audit registration order in createHandlers():
export function createHandlers() {
return [
...createSpecificRouteHandlers(), // ← first
...createWildcardFallbacks(), // ← last
];
}
State bleeds between tests causing non-deterministic failures
Cause: A resolver mutates mockState but afterEach never calls mockState.reset().
Fix: Confirm mockState.reset() is in the afterEach hook in setupTests.ts. If tests are parallelised with Vitest’s --pool=forks, each worker gets its own module instance — no sharing needed. With --pool=threads, workers share module scope; use vi.isolateModules() per test file or pass state via server.use() overrides rather than the global state object.
When to Advance
The implementation is complete when:
- All integration tests pass with
onUnhandledRequest: 'error'— no silent bypasses - The resilience test suite triggers and validates error boundaries at the configured fault rate
mockState.reset()inafterEachproduces zero test-order dependencies (run tests in random order with--randomizeto verify)- GraphQL operation mocks match the TypeScript return types generated from the schema (no
anycasts required) - Writing custom MSW response resolvers for each domain area is documented and discoverable by new team members
FAQ
How do I reset stateful MSW mock state between tests?
Call mockState.reset() — exported from your state module — in afterEach, not in afterAll. Reset the state object itself rather than stopping and restarting the server; server.resetHandlers() clears per-test server.use() overrides but does not touch your state module.
Can MSW intercept WebSocket connections for subscription testing?
MSW does not natively intercept WebSocket frames. Run a lightweight ws server on a fixed port (e.g. 9001) and configure your GraphQL client’s subscription transport to connect there during tests. Set process.env.REACT_APP_WS_URL=ws://localhost:9001 in your test environment. MSW handles the initial HTTP negotiation only in experimental builds.
Why do my MSW handlers produce different results in CI than locally?
The most common cause is Math.random() or Date.now() calls inside resolvers without a fixed seed. In CI, set MOCK_SEED=42 and seed your random number generator at startup. Also check that msw/node is used in Node environments — msw/browser requires a real Service Worker context and will silently no-op in Node.
What is the correct handler resolution order when multiple handlers match?
MSW resolves in registration order; the first match wins. Handlers prepended with server.use() in a test take precedence over the registry defaults for the duration of that test. server.resetHandlers() in afterEach restores the original registry order.
Related
- Writing Custom MSW Response Resolvers — deep dive into resolver composition, header manipulation, and streaming responses
- MSW Setup and Worker Registration — foundational Service Worker configuration before applying these patterns
- WireMock Standalone Configuration — complement MSW for server-side and non-browser network calls
- Request Interception Patterns — architectural context for where the Service Worker intercept layer sits
- Response Shaping Techniques — dynamic payload strategies that work alongside MSW handler factories
← Back to Tool-Specific Implementation & Setup