Subtopic · Network & API Mocking for Reliable Tests

MSW in JavaScript Test Suites

Most teams end up describing the same API three times: once in Jest with module mocks, once in Cypress with cy.intercept, once in Playwright with route handlers. The three descriptions drift, and the drift is invisible until a component passes its unit test and breaks in the browser. Mock Service Worker inverts that arrangement — handlers are written once and served either by a service worker in the browser or by an interceptor in Node — which makes it the natural companion to the framework-specific techniques in Network & API Mocking for Reliable Tests.

15 sections 3 child guides URL: /network-api-mocking-for-reliable-tests/msw-in-javascript-test-suites/
One handler set, two runtimes The same handlers are served by a service worker for browser tests and by a Node interceptor for unit and integration tests. handlers.jsone description of the API setupWorker — browserCypress, Playwright, dev mode setupServer — NodeJest, Vitest, server-side code a contract change is made once and every layer sees it immediately
The value is not the interception mechanism — it is that one description of the API serves every test level.

Prerequisites #

Requirement Version / setting Why it matters
msw 2.x The http/HttpResponse API differs substantially from 1.x
Node 18+ Native fetch interception in the Node runtime
Service worker file Generated into the public directory The browser runtime needs mockServiceWorker.js served from the app origin
Test setup files Per runner setupServer for Jest/Vitest, setupWorker for browser runs
onUnhandledRequest policy 'error' in CI Otherwise unmocked calls escape to the real network

Where a suite already mocks at the framework layer, MSW does not have to replace it wholesale — pairing it with the recorded-payload approach in Record & Replay HTTP Traffic is common, with recordings supplying realistic bodies and MSW supplying the routing.

Step-by-step implementation #

1. Describe the API once #

Handlers are plain functions over a request, returning a response. Keep them in a shared module that both runtimes import.

// mocks/handlers.js
// Trade-off: a shared handler set is the whole point and it becomes a shared
// dependency — a careless edit affects every test level at once.
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/invoices', ({ request }) => {
    const status = new URL(request.url).searchParams.get('status');
    return HttpResponse.json({
      invoices: status === 'open' ? [{ id: 'INV-1', amount: 1250 }] : [],
    });
  }),

  http.post('/api/invoices/:id/pay', async ({ params }) => {
    if (params.id === 'INV-DECLINED') {
      return HttpResponse.json({ error: 'card_declined' }, { status: 402 });
    }
    return HttpResponse.json({ id: params.id, status: 'paid' });
  }),
];

2. Start the Node runtime for unit and integration tests #

setupServer intercepts requests made by the process, which covers component tests in a DOM environment as well as server-side code.

// vitest.setup.js
// Trade-off: onUnhandledRequest 'error' is strict and will fail on requests you
// forgot — which is exactly the feedback that keeps the handler set complete.
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers.js';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());   // undo per-test overrides
afterAll(() => server.close());

resetHandlers() in afterEach is not optional. Without it a handler added inside one test keeps answering in the next, which is precisely the shared-state problem described in Test Isolation & State Leakage.

3. Override per test, narrowly #

Scenario-specific behaviour belongs in the test that needs it, layered on top of the shared defaults rather than edited into them.

// Trade-off: overrides keep the shared handlers clean and are invisible to a
// reader who only looks at handlers.js — keep them short and local.
test('shows a retry affordance when the list fails', async () => {
  server.use(
    http.get('/api/invoices', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))
  );

  render(<InvoiceList />);
  expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i);
});
Default handlers with per-test overrides Shared handlers provide the happy path; a test adds an override that is removed by resetHandlers afterwards. per-test override (server.use) — matched first shared handlers — the happy path every test starts from unhandled request → error, never a live call resetHandlers() removes the top layer between tests; forgetting it makes the override permanent
Three layers with a hard floor: defaults for reuse, overrides for scenarios, and an error for anything unmocked.

4. Serve the same handlers in the browser #

For Cypress and Playwright the handlers are served by a service worker registered inside the application. The worker file must be generated into the served directory and the worker must be started before the app makes its first request.

// mocks/browser.js
// Trade-off: a service worker is closer to real network behaviour than route
// interception and adds a registration lifecycle you must wait for.
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers.js';

export const worker = setupWorker(...handlers);
// src/main.jsx — start it only in test/dev builds
if (import.meta.env.MODE !== 'production') {
  const { worker } = await import('../mocks/browser.js');
  await worker.start({ onUnhandledRequest: 'error' });   // await before rendering
}

Awaiting start() before the application renders is what prevents the first request from escaping the worker — the classic symptom of a “sometimes the mock does not apply” flake, and the same lifecycle problem that makes teardown subtle in Tearing Down MSW Service Workers Between Tests.

5. Fail loudly on anything unmocked #

onUnhandledRequest: 'error' is the setting that turns a handler set into a guarantee. Without it, a request nobody wrote a handler for goes to the real origin — passing locally, failing whenever that origin is unavailable.

// Trade-off: 'error' will fail on genuinely irrelevant traffic such as source
// maps or telemetry; allowlist those explicitly rather than relaxing the policy.
server.listen({
  onUnhandledRequest(request, print) {
    if (request.url.includes('/telemetry')) return;   // deliberately ignored
    print.error();
  },
});

6. Model state carefully, or not at all #

Handlers are functions, so it is tempting to give them an in-memory store and let tests exercise create-read-update flows against it. That works, and it introduces the one thing mocking was supposed to remove: state shared between tests. A store declared at module scope is worker-wide, so a test that creates an invoice changes what every later test sees, and the failures move around when the order changes.

Two arrangements avoid it. The first is to keep handlers stateless and express variation through per-test overrides — the read returns whatever that test’s scenario needs, and no write is ever recorded. This is right for the large majority of component and page tests, which assert on rendering rather than on persistence.

The second is a store whose lifetime is a single test, created in a factory and installed through server.use(). The cost is a few more lines per test; the benefit is that the store cannot outlive the test that made it.

// Trade-off: a per-test store is more setup than a module-level one and it is
// the only version that cannot leak — the same argument as any other fixture.
function invoiceHandlers(initial = []) {
  const store = [...initial];                       // lives as long as the test
  return [
    http.get('/api/invoices', () => HttpResponse.json({ invoices: store })),
    http.post('/api/invoices', async ({ request }) => {
      const created = { id: `INV-${store.length + 1}`, ...(await request.json()) };
      store.push(created);
      return HttpResponse.json(created, { status: 201 });
    }),
  ];
}

test('creating an invoice adds it to the list', async () => {
  server.use(...invoiceHandlers());                 // fresh store per test
  // …
});

7. Know what MSW is not the right tool for #

MSW describes what an API returns. It is a poor fit for describing how the network behaves, and reaching for it there produces awkward tests.

Connection failures, aborted requests and hangs are expressed far more clearly with framework interception, where route.abort() and an unresolved handler say exactly what is happening — see Testing Offline and Connection Loss States. Artificial latency has the same problem: a delay inside a handler slows the suite and models a distribution badly, while a runner-level throttle models it properly, as in Simulating Slow 3G Conditions in Playwright.

Large realistic payloads are the third case. A handler returning a two-hundred-line inline object is unreadable and drifts from the real API; a recorded archive keeps the fidelity and the review signal. The healthiest arrangement in a mature suite is usually all three tools with clear boundaries: MSW for the API contract, recordings for realistic bodies, framework interception for network behaviour and per-spec edge cases.

Configuration reference #

Option Where Accepted values Default Effect on reliability
onUnhandledRequest listen / start 'warn' | 'error' | 'bypass' | function 'warn' 'error' prevents silent live calls; 'bypass' guarantees them
server.resetHandlers() afterEach not called Removes per-test overrides; omitting it leaks scenarios between tests
server.use() inside a test handlers Prepends handlers, so the most recent matching one wins
worker.start() app bootstrap awaited | not not awaited An unawaited start lets the first request escape
quiet start true | false false Silences the console banner in CI logs
waitUntilReady start true | false true Defers requests until the worker is active
Handler order handlers.js array order First match wins; a broad handler placed early shadows specific ones

Data-driven analysis #

  • Unhandled-request count. With 'error' this is zero by construction; while migrating, the count is a direct measure of how incomplete the handler set is, and it should trend to zero rather than being silenced.
  • Handler reuse ratio. The share of handlers used by more than one test level. A high ratio is the return on adopting MSW at all; a ratio near zero means you have three descriptions again, just in one file.
  • Override density. Overrides per test file. A few are healthy — they express scenarios. Many suggest the defaults do not represent a realistic baseline, and the shared set needs rework.
  • First-request escapes. Requests that occurred before the worker became active. Any non-zero value points at the bootstrap ordering rather than at the handlers, and it is the most common cause of intermittent browser-side mock failures.
  • Handler-to-endpoint coverage. Endpoints the application calls versus endpoints the handler set describes. The gap is the set of paths where tests are silently exercising nothing.
Where MSW fits against framework interception MSW covers unit, component and browser levels with shared handlers; framework interception remains best for per-test edge cases. MSW — shared handlers the same API description everywhere unit · component · browser best for the realistic baseline page.route / cy.intercept per-test, in the spec, visible inline aborts, delays, sequences best for edge cases and failures
These are complements: shared handlers for the baseline, framework interception for the awkward cases a spec should state locally.

Adopting it in an existing suite #

Introducing a shared mocking layer into a suite that already mocks three different ways is a migration, and treating it as one avoids the usual outcome — a fourth mocking approach sitting alongside the other three.

Start where the duplication hurts most, which is almost always a single API surface described in several places: the endpoint a component test stubs with a module mock, a Cypress spec stubs with cy.intercept, and a Playwright spec stubs with a route handler. Write handlers for that surface, wire the Node runtime into the unit tests, and delete the module mocks. That first slice proves the setup and produces an immediate, visible reduction in duplicated fixtures.

Take the browser runtime second, and expect the bootstrap ordering to be the awkward part rather than the handlers. Until worker.start() is awaited before the application renders, a small percentage of browser tests will see unmocked first requests, and the resulting flakiness will be blamed on MSW rather than on the ordering.

Leave the strictness switch for last. Turning on onUnhandledRequest: 'error' before the handler set is reasonably complete produces a wall of failures that stalls the migration; turning it on afterwards converts a mostly-complete set into a guaranteed one. Counting escapes first, as described in Handling Unhandled Requests and Passthrough in MSW, makes that final step a short, ordered task rather than a cliff.

What not to migrate is as important. Specs that exercise network behaviour — aborts, hangs, latency, retry sequences — should keep using framework interception, and specs that depend on large realistic payloads are better served by recorded archives. A migration that tries to move everything into handlers ends with a handler file nobody wants to read.

Common pitfalls & mitigation strategies #

  • Leaving onUnhandledRequest at the default. Unmocked requests reach the network and the suite depends on a real origin. Mitigation: 'error' in CI, with an explicit allowlist.
  • Not resetting handlers between tests. An override from one test answers the next. Mitigation: server.resetHandlers() in afterEach.
  • Not awaiting worker.start(). The first request escapes before the worker is active. Mitigation: await the start before rendering the application.
  • A broad handler placed before specific ones. http.get('/api/*') shadows every narrower route. Mitigation: order specific handlers first, and keep wildcards last.
  • Stateful handlers at module scope. A counter or an in-memory store persists across tests. Mitigation: keep scenario state inside the test and reset it explicitly.
  • A stale service-worker file. An outdated mockServiceWorker.js fails silently against a newer library version. Mitigation: regenerate it as part of the install script.
  • Using MSW for delays and aborts it is awkward at. Timeout and connection-failure scenarios read better as framework routes. Mitigation: use interception for those, as in Testing Offline and Connection Loss States.
MSW health scorecard Targets for unhandled requests, handler reuse, first-request escapes and endpoint coverage. 0unhandled requests > 60%handlers reused 0first-request escapes 100%endpoints described
Handler reuse is the metric that says whether the single-description promise is actually being realised.

Frequently Asked Questions #

Q: Is MSW a replacement for cy.intercept and page.route? A: For the baseline, yes; for edge cases, no. Shared handlers give every level the same realistic API, which is what stops unit and browser tests from disagreeing. Aborts, artificial delays, and per-attempt response sequences are clearer expressed inline in the spec with framework interception, where a reader can see them next to the assertion.

Q: Why do some requests bypass the worker in browser tests? A: Either the worker had not activated when the request was made — the fix is awaiting start() before the app renders — or the request came from a context the worker does not control, such as a different origin or a request issued by the test runner itself rather than the page. The Node runtime has an analogous case: requests made by the test process rather than by the code under test.

Q: How do I model a sequence, such as failing twice then succeeding? A: Keep a counter inside the test and close over it in an override, so the state’s lifetime is the test’s lifetime. Putting the counter at module scope makes it survive into the next test, which produces the classic “passes alone, fails in the suite” signature.

Q: Can handlers assert on the requests they receive? Yes, and it is underused. A handler has the request in hand, so validating the outgoing body against the provider’s schema — or simply asserting that a required header is present — turns the mock from something that always agrees into a check on the client. That catches a whole category of drift that response-focused testing never sees.

Q: How much of the API should the shared handler set describe? A: Every endpoint the application calls in the flows you test, and nothing more. A handler for an endpoint no test exercises is dead weight that still has to be maintained and reviewed; an endpoint the application calls with no handler is a gap that strict mode will surface immediately. The set should track real usage, which is why the endpoint-coverage metric is worth watching as the application grows.

Q: Does MSW work with server-side rendering? A: The Node runtime intercepts requests made by the rendering process, so a server-rendered page can be tested against the same handlers as the client. The subtlety is that a server-rendered application makes requests during render and then the hydrated client makes more, so both runtimes may be active in one test — and both must be pointed at the same handler set, or the two phases will render from different data. That mismatch produces hydration warnings which look like the timing problems described in Waiting for React Hydration Before Assertions but have a completely different cause.

Q: How do handlers interact with a client-side cache? Poorly, unless the cache is reset between tests. A cache-first policy can skip the network entirely, so the handler is never called and a test waiting on it hangs; a normalised cache can merge a previous test’s entity into this test’s render. Reset the client’s store in the same teardown that resets handlers, and keep one spec that exercises caching deliberately so the behaviour is not merely disabled everywhere.

Q: Do the same handlers work for GraphQL? A: Yes — MSW has a GraphQL namespace that matches on operation name rather than URL, which is the right level for an API where every request hits the same path. The framework-specific alternatives are covered in GraphQL & WebSocket Mocking and Stubbing GraphQL Queries in Cypress.

Explore next

Child guides in this section