Testland
Browse all skills & agents

msw-handlers

Authors Mock Service Worker (MSW) request handlers for both browser and Node.js test environments using the `http.get` / `http.post` / `HttpResponse.json` API, wires them via `setupWorker` (browser) or `setupServer` (Node), and manages the test lifecycle (`server.listen` / `resetHandlers` / `close`). Use when the project uses JavaScript / TypeScript and needs to mock fetch / XHR at the network layer for both Vitest / Jest unit tests and Cypress / Playwright integration tests.

msw-handlers

Overview

Mock Service Worker (MSW) intercepts HTTP requests at the network layer using Service Workers in the browser and a request- interception adapter in Node.js (msw-getting-started). The same handler set works for both - the test author writes one http.get mock and uses it from Vitest unit tests AND from a Cypress browser test.

When to use

  • The project is JavaScript / TypeScript.
  • Tests need to mock HTTP at the same layer as the SUT - intercepting actual fetch / XHR calls rather than stubbing the HTTP client library.
  • Both browser tests (Cypress / Playwright) and Node tests (Vitest / Jest) need the same mock data - MSW's cross-environment consistency is its key advantage.
  • The team wants per-test handler overrides (server.use(...)) layered on top of project-wide defaults.

If the project is JVM, use wiremock-stubs. For multi-protocol mocking, see mountebank-imposters.

Install

npm i msw --save-dev

(Per msw-getting-started.)

For browser setup, also generate the worker script:

npx msw init public/ --save

This places mockServiceWorker.js in your public directory; the worker script is what the browser registers to intercept requests.

Authoring handlers

Per msw-getting-started:

// src/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://api.example.com/user', () => {
    return HttpResponse.json({ id: 'abc-123', firstName: 'John' });
  }),

  http.post('https://api.example.com/orders', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ order_id: 42, ...body }, { status: 201 });
  }),

  http.get('https://api.example.com/orders/:id', ({ params }) => {
    return HttpResponse.json({ order_id: Number(params.id), status: 'shipped' });
  }),
];

Handler signature: http.<verb>(url, resolver). The resolver receives { request, params, cookies } and returns an HttpResponse.

HttpResponse static helpers

HelperEffect
HttpResponse.json(body, init?)JSON response with Content-Type: application/json.
HttpResponse.text(body, init?)Plain text response.
HttpResponse.error()Network-level error (e.g. simulate offline).
new HttpResponse(...)Full-control raw response.

The init object accepts status, statusText, headers - matching the standard Response API.

Pattern matching

PatternNotes
'https://api.example.com/orders/:id'Path param exposed via params.id.
'/api/orders'Same-origin path; matches relative to current host.
'*\\/orders'Wildcards via standard URL pattern.
/^\\/api\\/orders\\/[0-9]+$/Regex match.

Browser setup - setupWorker

// src/mocks/browser.js
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

// Start the worker only in development / test
if (process.env.NODE_ENV !== 'production') {
  worker.start();
}

(Per msw-getting-started.)

In a Storybook + MSW combo, kick off worker.start() from the preview file. For Cypress: register the worker before any test that depends on the mocks.

Node setup - setupServer

// src/mocks/node.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// vitest.setup.js (or jest.setup.js)
import { server } from './src/mocks/node';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

(Per msw-getting-started.)

HookWhenPurpose
server.listen()beforeAllActivate mocking.
server.resetHandlers()afterEachClear per-test overrides.
server.close()afterAllTear down; release resources.
server.use(...)Per-testAdd / override handlers for the current test only.

onUnhandledRequest: 'error' is the canonical strict mode - fails the test if the SUT makes any HTTP call that doesn't have a matching handler. Catches "we forgot to mock that endpoint" silently passing.

Per-test handler overrides

import { http, HttpResponse } from 'msw';
import { server } from './src/mocks/node';

test('handles 500 error gracefully', async () => {
  server.use(
    http.get('https://api.example.com/user', () =>
      new HttpResponse(null, { status: 500 })
    )
  );

  const result = await fetchUser();
  expect(result.error).toBe('server error');
});
// `server.resetHandlers()` in afterEach reverts to the default handlers

This pattern is the canonical way to test error / edge paths without polluting the default-handlers set.

CI integration

# .github/workflows/test.yml
- run: npm ci
- run: npm test          # Vitest / Jest pick up vitest.setup / jest.setup automatically

For Cypress + MSW, also run npx msw init in CI to ensure the service worker script is in public/.

Anti-patterns

Anti-patternWhy it failsFix
onUnhandledRequest: 'bypass' (the default before strict mode)Test passes despite the SUT calling unmocked endpoints; real network bleeds in.Always 'error' in CI.
Per-test handler definition (no shared handlers.js)Duplication; drift between tests; hard to maintain.One handlers.js per project; server.use() for overrides.
Forgetting resetHandlers()Handlers leak across tests; later test fails because earlier test's override is still active.Always afterEach(() => server.resetHandlers()).
Using MSW for tests that exercise network errors onlyMSW intercepts at HTTP; for low-level network errors, use HttpResponse.error() or simulate via the test framework.The two are different layers; MSW handles HTTP, lower errors need other tooling.
Mocking the same endpoint in browser AND node setups separatelyDrift; bug fix in one is missed in the other.Single handlers.js consumed by both setupWorker and setupServer.
Including MSW in the production bundleBloat; possibly leaks mocks to real users.Tree-shake by importing only in process.env.NODE_ENV !== 'production' branches; never import { worker } at top level.

Limitations

  • Service Worker scope. Browser MSW only intercepts requests from the same origin / path scope as the worker registration. Cross-origin requests need explicit handler URLs.
  • Streaming responses. SSE / chunked responses work but are more delicate than JSON; consult MSW docs for the streaming API.
  • No native scenario state. Stateful workflows ("after POST, GET returns the new item") need to manage state in your handlers manually (e.g. with a closure-scoped variable).

References