Browser Test Runner Integration

This guide covers connecting a mock layer to the runners that drive a real browser — Playwright, Cypress and Vitest browser mode — so end-to-end specs run against deterministic responses. It covers where to intercept, how to install the mock before the first request, and how to keep parallel specs isolated. It does not cover writing the handlers themselves; that is MSW setup and WireMock configuration.

Prerequisites

  • A browser runner installed — Playwright 1.4x, Cypress 13+, or Vitest 2.x with browser mode
  • An existing handler set that already works in unit tests, so the end-to-end layer reuses rather than duplicates it
  • The application’s dev server startable from a single command, with a known port
  • mockServiceWorker.js present in the public directory if you intend to intercept in the page
  • Familiarity with running MSW in GitHub Actions, since the CI wiring builds on it

Where to intercept, and why it decides everything

A browser test has two candidate interception points, and they see different traffic.

Runner-level interception — Playwright’s page.route, Cypress’s cy.intercept — happens in the driver, outside the page. It sees every request the browser makes, including ones from iframes, workers and the document itself, and it requires nothing to be installed in the application. Its weakness is that the handlers live in test code, so they drift from the handlers your dev server uses.

In-page interception — MSW’s Service Worker — happens inside the page. It sees only what the page’s own JavaScript requests, but it uses the same handler set as your unit tests and your dev server, so there is exactly one definition of what the API returns.

The decision is not really about capability. It is about how many sources of truth you are willing to maintain.

Runner-level versus in-page interception A browser page sits inside a browser context, which sits inside the runner-driven browser. Runner-level interception wraps the whole browser and therefore sees document requests, iframe requests and worker requests as well as page fetches. In-page Service Worker interception sits inside the page and sees only fetches made by page JavaScript, but it reuses the same handler module as the unit tests and the dev server. Runner-level interception (page.route / cy.intercept) Browser context document request the HTML itself iframe / web worker outside the page's scope page JavaScript fetch() the only traffic an in-page worker can see In-page interception (MSW worker) handlers.ts one module, three consumers unit tests · dev server · e2e Sees: page fetch only Cannot see: document, iframes, workers Gains: no second definition to drift Runner-level sees more traffic; in-page keeps one source of truth. Pick per suite, and never point both at the same endpoint. When both are active, whichever intercepts first wins silently — and which one that is depends on load order.

The last line is the trap worth naming explicitly. A team that adds cy.intercept for one flaky endpoint while MSW is already running ends up with two definitions, one of which is invisible. The next person to change the MSW handler will find the change has no effect and no error.

Phase 1 — Playwright with runner-level routes

Playwright routes are registered on the context, and they must exist before navigation:

// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';

export const test = base.extend<{ mockApi: void }>({
  mockApi: [
    async ({ context }, use) => {
      await context.route('**/api/orders', async (route) => {
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify([
            { id: 'ord_1', status: 'paid', total: 4250 },
            { id: 'ord_2', status: 'pending', total: 1899 },
          ]),
        });
      });

      // Anything not explicitly mocked must fail loudly rather than
      // silently reaching a real API from CI.
      await context.route('**/api/**', (route) =>
        route.abort('blockedbyclient')
      );

      await use();
    },
    { auto: true },
  ],
});

export { expect };

The catch-all abort is the load-bearing part. Without it, an endpoint nobody remembered to mock quietly hits the real service, and the suite is green because production answered — the same failure mode onUnhandledRequest: 'error' prevents in MSW.

Route order matters: Playwright matches the most recently registered route first, so the specific handler must be registered before the catch-all, as above.

Phase 2 — MSW inside the page

To reuse one handler set, start the worker in the application itself and have the runner wait for it. The application decides whether to start based on a flag, so production bundles never include it:

// src/mocks/start.ts
export async function startMocksIfEnabled(): Promise<void> {
  if (!import.meta.env.DEV && !import.meta.env.VITE_ENABLE_MOCKS) return;

  const { worker } = await import('./browser');
  await worker.start({
    onUnhandledRequest: 'error',
    quiet: true,
    serviceWorker: { url: '/mockServiceWorker.js' },
  });

  // Signal readiness so the test runner can wait on it deterministically.
  (window as unknown as { __mocksReady?: boolean }).__mocksReady = true;
}
// src/main.tsx
import { startMocksIfEnabled } from './mocks/start';

// Awaiting before render is what guarantees the first fetch is intercepted.
await startMocksIfEnabled();
renderApp();

Then the fixture waits for the flag rather than for an arbitrary timeout:

// e2e/fixtures.ts
export const test = base.extend<{ mocksReady: void }>({
  mocksReady: [
    async ({ page }, use) => {
      await page.goto('/');
      await page.waitForFunction(() => (window as any).__mocksReady === true);
      await use();
    },
    { auto: true },
  ],
});

waitForFunction on an explicit readiness flag replaces the waitForTimeout(500) that otherwise creeps into every browser suite. Timeout-based waits are the largest single source of flake in end-to-end tests, and here there is a real signal to wait on.

Phase 3 — isolation across parallel specs

Browser runners parallelise by default, and mock state is the thing most likely to leak. Three rules keep it contained.

One context per spec. A Playwright context owns its own service workers, storage and cookies. Reusing a context across a file means one spec’s overrides are visible to the next.

Reset state, not just handlers. Resetting handlers restores the default responses but leaves any store the handlers wrote to. A spec that created three orders leaves them behind for the next one unless the store is cleared as well.

Never key mock state on a global. Module-level mutable state inside handlers is shared by every spec in the same worker process. Key it on something request-scoped — a header the spec sets, or a per-context identifier.

// e2e/fixtures.ts — per-spec scoping via a header
export const test = base.extend<{ scopeId: string }>({
  scopeId: async ({ context }, use, testInfo) => {
    const id = `spec_${testInfo.testId}`;
    await context.setExtraHTTPHeaders({ 'x-mock-scope': id });
    await use(id);
  },
});

The handler then reads x-mock-scope and looks up a store keyed by it, so two specs mutating “the same” order never collide. This is the browser-level equivalent of the per-request fault selection used in error and latency simulation.

Scoped state keeps parallel specs from colliding Two specs run concurrently in the same worker process. Each has its own browser context that attaches a distinct x-mock-scope header. Both hit the same handler module, but the handler looks the store up by scope, so spec A's created order is invisible to spec B. A note contrasts this with a single shared module-level store, where the two specs would see each other's writes. Spec A — context 1 x-mock-scope: spec_a creates ord_1 Spec B — context 2 x-mock-scope: spec_b asserts the list is empty One handler module store.get(scopeHeader) never a bare module global store["spec_a"] { ord_1: paid } invisible to spec B store["spec_b"] { } — empty, as asserted unaffected by spec A With one shared module-level store instead, spec B sees ord_1 and fails — but only when the two happen to run concurrently.

Verification steps

  • npx playwright test --workers=1 passes — the baseline
  • npx playwright test --workers=4 passes with identical results — proves isolation, and is the run that catches shared state
  • npx playwright test --repeat-each=3 passes — proves state is reset rather than merely initialised
  • Removing one handler causes a specific spec to fail with an aborted request, not a silent pass against a real API
  • npx playwright test --trace on shows the mocked response in the network panel, confirming which layer actually served it
  • The suite passes with the dev server started fresh and with one already running

The --workers=1 versus --workers=4 pair is the single most valuable check here. A suite that passes serially and fails in parallel has shared state, and the difference between those two commands localises it in seconds.

Reading a browser-suite failure Five symptoms mapped to the layer responsible. A blocked request means a missing route. A real network hit means the catch-all is absent. Passing serially and failing in parallel means shared state. A first-request escape means the mock installed after navigation. An unexpected response means two interception layers are competing on one endpoint. Symptom Points at Fix ERR_BLOCKED_BY_CLIENT an endpoint nobody mocked add the route, keep the catch-all a real remote address in the trace no catch-all registered abort anything unmatched passes at 1 worker, fails at 4 state shared across specs a fresh context and a scoped store only the first request escapes the mock installed after navigation register in an auto fixture an unexpected response body two layers on one endpoint pick one layer per endpoint The trace viewer answers the first four in seconds: a fulfilled request shows no remote address.

Troubleshooting

The first request of every spec escapes the mock. Navigation started before the route was registered or before the worker was ready. Move the registration into an auto fixture, and have the application await worker start before rendering anything that fetches.

route.fulfill has no effect. Another route registered later is matching first — Playwright evaluates the most recent registration first. Check for a broad **/api/** route added after the specific one and reorder.

Cypress cy.intercept and MSW both claim the endpoint. Whichever intercepts first serves it, and that depends on load order rather than intent. Pick one layer per endpoint. If you need runner-level assertions on a request that MSW handles, use MSW’s own server.events listeners instead of adding a second interceptor.

The Service Worker persists between specs. Service workers are scoped to the browser context, so a reused context keeps the previous registration and its state. Create a fresh context per spec; if the runner reuses one deliberately, call worker.resetHandlers() and clear the store from an afterEach inside the page.

Everything works locally, everything times out in CI. Usually the dev server is not up when the first spec navigates. Use the runner’s webServer configuration with an explicit readiness URL rather than a sleep, so the suite waits for a real signal — the same lifecycle discipline described in mocks in CI pipelines.

When to advance

This is in place when the end-to-end suite passes identically at one worker and at four, when no spec can reach a real API even by accident, when one handler module serves unit tests, the dev server and the browser suite, and when a failed spec’s trace shows exactly which layer answered each request. The next step is running the whole thing per pull request against an isolated stack — see ephemeral preview environments.


What browser tests should and should not assert

A browser suite is expensive to run and expensive to maintain, so what it asserts matters more than in a unit suite where an extra spec costs milliseconds.

Assert on things that only a real browser can tell you. Layout that depends on actual text metrics, focus order under real keyboard navigation, whether a click on a covered element reaches its target, whether a Service Worker actually registers, whether a file download starts. These are the things a jsdom test cannot answer, and they are the reason to pay for a browser at all.

Do not re-assert what a unit test already covers. A browser spec that checks a currency is formatted correctly is paying browser prices for a pure-function assertion. Every such spec makes the suite slower and, worse, makes it fail for reasons unrelated to the browser, which trains people to rerun rather than to read.

Assert on requests as well as pixels. A rendered success message proves something happened; it does not prove the right payload was sent, or that it was sent once. Recording requests and asserting on their count and content catches double-submits, missing idempotency keys and wrong filters — none of which have any visual symptom.

Assert that nothing escaped. The single most valuable browser-suite assertion is that no request reached a real service. It costs one catch-all route and it prevents the failure mode where the suite is green because production answered.

Be careful what you assert about timing. A browser suite is the worst place to assert on durations, because the runner’s machine load dominates. Assert on ordering and on state transitions, both of which are stable, rather than on elapsed milliseconds, which are not.

There is one more principle worth stating: a browser spec should fail for exactly one reason. A spec that navigates through four screens to reach the thing under test fails whenever any of the four changes, and the failure message names the wrong screen. Seed the state that gets you to the interesting screen — through the mock, not through the UI — and let the spec exercise only the part that needs a browser.

The one number worth watching

For a browser suite, the measurement that predicts everything else is the ratio between the serial and parallel run results. They should be identical.

The moment they diverge, the suite has shared state, and every subsequent problem — intermittent failures, results that differ between a laptop and CI, a spec that only fails when a colleague adds an unrelated test — is downstream of that one fact. Because the check is two commands and takes minutes, running it on a schedule rather than only when something breaks catches the divergence in the week it is introduced, when it is still one change to reverse.

FAQ

Should I use Playwright’s route interception or MSW in the page?

Use Playwright routes when the test is fundamentally about the network contract and you want the expectation to live in the spec file next to the assertion. Use MSW in the page when you want one handler set shared between unit tests, the dev server and the end-to-end suite, which is the stronger default for most product teams. What you must not do is point both at the same endpoint: whichever intercepts first wins, silently, and which one that is depends on load order rather than on anything a reader of the test can see.

Why does my mock miss the first request of the page?

Because it was installed after navigation began. Route handlers have to be registered before page.goto, and a Service Worker needs a completed registration before the first fetch leaves the page. Install the mock in a fixture that runs ahead of navigation, and have the application await worker readiness before rendering anything that fetches. A readiness flag on window gives the runner something real to wait for instead of a fixed timeout.

How do parallel specs avoid sharing mock state?

Give every spec its own browser context. A context owns its service workers, storage and cookies, so mock state cannot leak between contexts even when specs run concurrently in one browser process. Where handlers keep their own store, key it on a per-spec header rather than on a module global — a module global is shared by every spec in the worker process and produces failures that only appear under parallelism.


← Back to CI/CD & Test Integration