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.jspresent 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.
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.
Verification steps
-
npx playwright test --workers=1passes — the baseline -
npx playwright test --workers=4passes with identical results — proves isolation, and is the run that catches shared state -
npx playwright test --repeat-each=3passes — 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 onshows 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.
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.
Related
- Mocking APIs in Playwright Tests — the Playwright route and fixture pattern in full
- Using MSW with Cypress Component Tests — sharing one handler set with the component runner
- Running MSW in GitHub Actions — the CI job these suites run inside
- Per-Pull-Request Mock Stacks — giving each branch its own isolated stack
- Mock Service Worker (MSW) Setup — the handler set this guide reuses
← Back to CI/CD & Test Integration