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: true enabled in tsconfig.json
  • Node.js 20 LTS (for msw/node in Jest/Vitest environments)
  • Familiarity with the request interception pattern — specifically how middleware captures traffic before it reaches the network
  • @faker-js/faker or equivalent if you are generating dynamic payloads (optional but recommended)

Advanced MSW Handler Architecture A flow diagram showing how a browser request passes through the MSW Service Worker, is matched against the handler registry, optionally reads from or writes to the state store, and either returns a mocked response or passes through to a WireMock backend for non-browser calls. Browser fetch() / XHR Service Worker msw/browser intercepts fetch Handler Registry ordered resolver chain (first wins) State Store isolated per suite Mock Response JSON / error / delayed payload passthrough() for SSR / non-browser → WireMock / real API

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 test with MSW_FAULT_RATE=0 and confirm no tests fail due to unhandled requests (the onUnhandledRequest: 'error' setting surfaces these immediately)
  • Run npm test with MSW_FAULT_RATE=0.5 and 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 enabled appears in the console and that /api/* requests show (from ServiceWorker) in the initiator column
  • Assert that server.resetHandlers() in afterEach prevents state leaking between tests: run two tests in sequence where the first uses server.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() in afterEach produces zero test-order dependencies (run tests in random order with --randomize to verify)
  • GraphQL operation mocks match the TypeScript return types generated from the schema (no any casts 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.


← Back to Tool-Specific Implementation & Setup