Article · Network & API Mocking for Reliable Tests

Sharing MSW Handlers Between Jest and Playwright

The promise of writing the API description once only pays off if both runtimes genuinely load the same file. In practice they often do not: the Node tests import handlers from source while the browser tests get a bundled copy that was built an hour ago, or the browser bundle silently excludes handlers that use Node-only imports. This guide takes the shared-handler idea from MSW in JavaScript Test Suites and shows how to wire it so the two runtimes cannot diverge.

12 sections URL: /network-api-mocking-for-reliable-tests/msw-in-javascript-test-suites/sharing-msw-handlers-between-jest-and-playwright/
Shared core with runtime-specific entry points A runtime-agnostic handlers module is imported by a Node setup and a browser setup; only the setup files differ. mocks/handlers.jsno Node or DOM imports mocks/node.js — setupServerJest / Vitest setup file mocks/browser.js — setupWorkerstarted by the app in test builds a handler edit reaches both runtimes on the next run — no copy, no sync step
The only files that may differ between runtimes are the two setup entry points; everything else is shared by import.

Root cause #

Divergence creeps in through the runtime boundary. setupServer is a Node API and setupWorker is a browser API, so they cannot live in the same module — and once there are two modules, it is easy for each to grow its own handler list. The moment that happens the guarantee is gone: a unit test asserts against one description of the API and a browser test against another, and the difference between them is invisible until a component behaves differently in the two environments.

The second source is bundling. The browser runtime only sees what the application’s bundler included. A handlers module that imports node:fs to read a fixture, or that reads process.env, either fails to build or gets silently stubbed, and the resulting handler set in the browser is not the one the Node tests use. The rule that avoids this is simple to state and easy to violate under time pressure: the shared module must contain nothing runtime-specific — no filesystem access, no process, no DOM.

The third is lifecycle rather than content. Even with identical handlers, the two runtimes start differently: setupServer intercepts as soon as listen() is called, while setupWorker requires a service worker to register and activate, which is asynchronous and can lose the application’s first request. Handlers being identical is necessary and not sufficient; the browser side additionally has to be ready before the app runs.

Step-by-step fix #

1. Put handlers in a runtime-agnostic module #

One file, no imports that only exist in one environment, no reading of files or environment variables.

// mocks/handlers.js — importable from Node and from a browser bundle
// Trade-off: fixtures must be inline or imported as JSON rather than read from
// disk, which makes large payloads more awkward and keeps the module portable.
import { http, HttpResponse } from 'msw';
import invoices from './fixtures/invoices.json' with { type: 'json' };

export const handlers = [
  http.get('/api/invoices', () => HttpResponse.json(invoices)),
  http.get('/api/invoices/:id', ({ params }) => {
    const found = invoices.invoices.find((i) => i.id === params.id);
    return found
      ? HttpResponse.json(found)
      : HttpResponse.json({ error: 'not_found' }, { status: 404 });
  }),
];

2. Give each runtime a thin entry point #

The entry points contain the runtime-specific call and nothing else — no handler definitions, so there is nowhere for them to drift.

// mocks/node.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';
export const server = setupServer(...handlers);
// mocks/browser.js
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers.js';
export const worker = setupWorker(...handlers);

A lint rule that forbids importing msw/node from anything the browser bundle can reach makes the separation enforceable rather than conventional.

3. Start the worker before the application renders #

The browser side needs the worker active before the first request. Awaiting the start inside the application’s bootstrap — behind a mode check — is the only reliable ordering.

// src/main.jsx
// Trade-off: this puts test scaffolding in application code, gated by build
// mode; the alternative is a race that fails a few percent of the time.
async function bootstrap() {
  if (import.meta.env.MODE === 'test') {
    const { worker } = await import('../mocks/browser.js');
    await worker.start({ onUnhandledRequest: 'error', quiet: true });
  }
  renderApp();
}
bootstrap();

The activation lifecycle, and why teardown between tests needs the same care, is covered in Tearing Down MSW Service Workers Between Tests.

Worker activation versus the first request Rendering before the worker activates lets the first request escape; awaiting the start closes the gap. not awaited start() called app fetches — escapes worker activates too late awaited await start() worker activates app fetches — mocked a few percent failure rate on the unawaited path — the classic "the mock did not apply" flake
The ordering bug is intermittent by nature: on a fast machine activation usually wins the race, and in CI it sometimes does not.

4. Give both runtimes the same override mechanism #

Tests should express scenarios the same way regardless of level. Wrap the runtime difference behind one helper so a spec reads identically in Jest and in Playwright.

// mocks/override.js — Node side
// Trade-off: an abstraction over two APIs is one more indirection, and it keeps
// scenario code identical across levels, which is the point of sharing at all.
import { server } from './node.js';
export const useScenario = (...handlers) => server.use(...handlers);
// In a Playwright test the same scenario is applied through the page:
// Trade-off: browser overrides must cross into the page context, so they are
// expressed as a serialisable instruction rather than a function reference.
await page.evaluate(() => window.__msw.use(
  window.__mswHttp.get('/api/invoices', () =>
    window.__mswResponse.json({ error: 'boom' }, { status: 500 }))
));

Where that indirection gets awkward — and it does — the pragmatic answer is to keep browser-level failure scenarios in page.route instead, and reserve shared handlers for the realistic baseline.

5. Verify the two runtimes agree #

The guarantee is worth a test. Assert that both entry points expose the same handler count and the same set of matched paths, so a handler added to one and not the other fails immediately.

// Trade-off: a meta-test that checks the harness rather than the product, and
// it is the only thing that catches divergence before it causes confusion.
import { handlers } from '../mocks/handlers.js';

test('handlers module is runtime-agnostic', async () => {
  const source = readFileSync('mocks/handlers.js', 'utf8');
  expect(source).not.toMatch(/from ['"]node:/);
  expect(source).not.toMatch(/msw\/node|msw\/browser/);
  expect(handlers.length).toBeGreaterThan(0);
});

6. Keep fixtures importable, not read from disk #

Large payloads tempt people into readFileSync, which immediately makes the module Node-only. Import JSON instead, and if a payload is too large to import comfortably, that is a signal it belongs in a recorded archive rather than in the shared handler set — the split described in Record & Replay HTTP Traffic.

Pitfalls #

  • Two handler lists. Each runtime grows its own and the guarantee is silently lost. Mitigation: one shared module, thin entry points, and a test that checks it.
  • Node-only imports in the shared module. The browser bundle breaks or stubs them. Mitigation: forbid node: imports there by lint rule.
  • Not awaiting worker.start(). The first request escapes intermittently. Mitigation: await inside bootstrap, before rendering.
  • Different unhandled-request policies per runtime. One level catches gaps, the other hides them. Mitigation: set 'error' in both.
  • Overrides that outlive the test. Node’s server.use persists until reset. Mitigation: resetHandlers() in afterEach on both sides.
  • A stale generated worker file. The browser runtime silently misbehaves after an upgrade. Mitigation: regenerate mockServiceWorker.js in the install script.
What belongs in the shared module and what does not Route matching and response shapes are shared; setup calls, filesystem access and environment reads are not. shared route matchers, response bodies status codes, imported JSON portable by construction runtime-specific setupServer / setupWorker fs reads, process.env, DOM access keep out of the shared module
The rule is mechanical enough to lint: anything that cannot run in both environments does not belong in the shared file.

Reliability targets #

Metric Target Notes
Handler definitions outside the shared module 0 Entry points contain setup only
Runtime-specific imports in the shared module 0 Enforced by lint and a meta-test
First-request escapes in browser runs 0 Achieved by awaiting start()
Unhandled-request policy parity Both 'error' Same strictness at every level
Handler reuse across levels > 60% The measurable return on sharing
Shared-handler scorecard Targets for handler location, runtime-specific imports, first-request escapes and reuse. 1handler module 0runtime imports 0first-request escapes > 60%handler reuse
One handler module with zero runtime-specific imports is the whole structural requirement; the rest follows.

Frequently Asked Questions #

Q: Component tests pass but the Playwright test sees different data. What diverged? A: Check that the browser bundle actually included the shared module rather than a stale copy, and that both runtimes are pointed at the same file. The usual cause is a second handler list created for the browser months ago and edited since. The meta-test in step 5 exists to catch exactly this.

Q: Should Playwright tests use MSW at all, given page.route exists? A: Use MSW for the realistic baseline so browser tests see the same API as component tests, and page.route for anything the spec should state locally — a specific failure, a delay, a sequence of responses. Mixing them is normal; the ordering rule is that a route registered later takes precedence over the worker.

Q: How do I apply a scenario override from a Playwright test? A: Either expose a small control surface on window in test builds and drive it with page.evaluate, or handle that scenario with page.route instead. The second is usually clearer for one-off failures; the first is worth building only if many browser specs need the same scenario vocabulary as the unit tests.

Q: How do we stop the shared handler set from becoming a dumping ground? A: Give it the same review standards as production code and keep a clear rule about what belongs: the realistic baseline for endpoints the application genuinely calls, and nothing scenario-specific. Scenario behaviour lives in the test that needs it. When the shared file starts accumulating variants — handlersWithEmptyList, handlersForAdmin — that is the signal to convert them into per-test overrides or into a factory that takes parameters.

Q: Does sharing handlers make tests slower? A: Marginally, and in the opposite direction from what people expect: the Node interceptor adds negligible overhead, while the browser worker adds a registration step at bootstrap. Both are dwarfed by the time saved not maintaining three descriptions of the same API.