Mocking Network in Playwright Component Tests

A component mounted in Playwright Component Testing runs inside a real browser page, so any fetch it fires hits the live network unless you intercept it first. This guide shows React 18/19 developers using @playwright/experimental-ct-react 1.4x how to control that traffic with Playwright’s built-in routing — fulfilling JSON responses, rewriting upstream payloads, simulating errors and latency, and aborting unexpected calls — and explains why the browser Service Worker that powers Mock Service Worker does not work in the CT sandbox. The goal is a deterministic component suite where every request resolves to a stub you wrote.

Root Cause Analysis

CT renders the component in a genuine browser context. That fidelity is the point — real layout, real events — but it means the network stack is also real. An unstubbed request leaves the page, reaches DNS, and either succeeds against production (dangerous) or times out (flaky). Either way the test is no longer deterministic.

Teams reach instinctively for MSW because it is the default for in-browser mocking, but MSW’s browser mode installs a Service Worker to intercept requests, and Service Workers are blocked in the CT context. There are two reasons. First, CT runs each mount in an isolated, ephemeral page that is torn down per spec; a Service Worker’s registration and activation lifecycle is asynchronous and outlives that page, creating races where the worker isn’t active when the first request fires. Second, Playwright already owns the network layer at the browser-protocol level through page.route(), which sits below the Service Worker and intercepts every request type deterministically and synchronously from the test’s perspective. Stacking a Service Worker on top is both redundant and a source of nondeterminism, so CT configs set serviceWorkers: 'block'. The correct tool in CT is Playwright’s own routing API.

The distinction is easier to hold once you see where each interceptor sits in the request path. A Service Worker is application-level code that lives inside the page, so it can only catch a request after the page has booted, the worker has registered, and the browser has activated it — a sequence with real latency that a fast mount can outrun. Playwright’s page.route() hooks the browser at the protocol layer, beneath the page, so it is armed the instant the context is created and sees every request the page will ever make, including the very first fetch fired synchronously inside a mount effect. That ordering guarantee is exactly what makes CT stubs deterministic where a Service Worker cannot be.

Where page.route intercepts versus a Service Worker A request from the component passes an in-page Service Worker layer that is blocked in CT, then reaches Playwright's protocol-level route handler before it can leave for the network. Component fetch() fires inside mount Service Worker blocked in CT page.route() protocol layer, always armed real network never reached fulfilled here, so the request stops before the network
The route handler sits below the page, so it catches the first request while an in-page Service Worker is still activating.

Reproducible Setup

A component that fetches on mount is the canonical case.

// src/UserList.tsx
import { useEffect, useState } from 'react';

type User = { id: string; name: string };

export function UserList() {
  const [users, setUsers] = useState<User[] | null>(null);
  const [error, setError] = useState(false);

  useEffect(() => {
    fetch('/api/users')
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then(setUsers)
      .catch(() => setError(true));
  }, []);

  if (error) return <p role="alert">Failed to load users</p>;
  if (!users) return <p>Loading…</p>;
  return (
    <ul aria-label="Users">
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

Ensure the CT config blocks Service Workers so no stray worker competes with routing.

// playwright-ct.config.ts (excerpt)
use: {
  contextOptions: { serviceWorkers: 'block' },
},

Implementation

Fulfill a JSON response with page.route

Register the route before mounting so the stub is in place when useEffect fires.

// src/UserList.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { UserList } from './UserList';

test('renders users from a stubbed endpoint', async ({ mount, page }) => {
  await page.route('**/api/users', (route) =>
    route.fulfill({
      status: 200,
      json: [
        { id: '1', name: 'Ada' },
        { id: '2', name: 'Grace' },
      ],
    }),
  );

  const component = await mount(<UserList />);
  await expect(component.getByRole('listitem')).toHaveCount(2);
  await expect(component).toContainText('Ada');
});

route.fulfill({ json }) sets the Content-Type to application/json and serializes the body for you. The glob **/api/users matches regardless of origin, which keeps the stub robust against baseURL changes.

Every route handler ultimately picks one of four terminal actions, and choosing the right one is what lets a single API model success, corruption, transport failure, and pass-through without a real backend. The diagram below is the mental model to keep in view as the examples accumulate.

The four terminal actions of a Playwright route handler An intercepted request can be fulfilled with a stub, fetched and modified, aborted as a transport failure, or continued to the real network. intercepted request route handler runs fulfill return a stub fetch + fulfill rewrite real body abort transport failure continue pass to network deterministic runs use the first three; continue is the escape hatch
Every handler resolves to one of four actions; the first three keep a suite fully deterministic.

Use router.route for stubs shared across a spec file

When several tests in a file need the same baseline stub, register it once on the worker-scoped router fixture instead of repeating page.route in every test.

import { test as base, expect } from '@playwright/experimental-ct-react';

const test = base.extend({
  page: async ({ page }, use) => {
    await page.route('**/api/config', (route) =>
      route.fulfill({ json: { featureFlag: true } }),
    );
    await use(page);
  },
});

Simulate errors, latency, and malformed bodies

Deterministic failure injection is how you exercise error boundaries without a real flaky backend.

test('shows an alert on a 500', async ({ mount, page }) => {
  await page.route('**/api/users', (route) =>
    route.fulfill({ status: 500, body: 'upstream down' }),
  );
  const component = await mount(<UserList />);
  await expect(component.getByRole('alert')).toBeVisible();
});

test('handles a network-level failure', async ({ mount, page }) => {
  await page.route('**/api/users', (route) => route.abort('failed'));
  const component = await mount(<UserList />);
  await expect(component.getByRole('alert')).toBeVisible();
});

For latency, wait inside the handler before fulfilling — keep the value fixed, never random, so the run stays reproducible.

await page.route('**/api/users', async (route) => {
  await new Promise((r) => setTimeout(r, 300)); // deterministic delay
  await route.fulfill({ json: [] });
});

Modify a real upstream response

Sometimes you want the real shape but a tweaked field. Fetch the original inside the handler and rewrite it.

await page.route('**/api/users', async (route) => {
  const response = await route.fetch();
  const data = await response.json();
  await route.fulfill({ json: data.map((u: { name: string }) => ({ ...u, name: 'REDACTED' })) });
});

Fail loudly on unexpected requests

Add a catch-all that aborts anything you did not explicitly stub, so a forgotten endpoint surfaces as an obvious failure instead of a silent live call.

test.beforeEach(async ({ page }) => {
  await page.route('**/*', (route) => {
    const url = route.request().url();
    if (url.includes('/api/')) return route.abort('blockedbyclient');
    return route.continue();
  });
});

This mirrors the onUnhandledRequest: 'error' discipline used in the Node-side external service simulation tier — both make unmocked traffic a hard failure.

The reason a catch-all matters more in CT than in a jsdom suite is that a real browser page will happily resolve an unstubbed URL against production, so a forgotten endpoint does not fail loudly — it succeeds quietly against live data, corrupting the run in ways that only surface later as flakiness or, worse, as writes to a real system. Ordering matters here too: register the specific stubs first and the broad abort last, because Playwright evaluates route handlers in reverse registration order, so the most recently added catch-all runs first unless you account for it. A pragmatic pattern is to install the abort in a beforeEach and let individual tests layer their specific page.route() calls on top, which keeps the safety net in place while each test still overrides only the endpoints it cares about.

The modify-in-place pattern deserves a caution of its own. Calling route.fetch() inside a handler re-issues the request to the real upstream, so the very determinism you are trying to preserve now depends on a live service being reachable and stable. Reserve response rewriting for the rare case where you genuinely need production’s shape but a single tweaked field; for everything else, a hand-written stub is faster, hermetic, and immune to upstream drift. When you do rewrite, guard against re-interception loops by scoping the route narrowly so the follow-up fetch() does not match the same handler and recurse.

Verification

Confirm three things. First, the stub registered before the request fired: if assertions intermittently see Loading…, the route was added after mount() triggered the fetch, so move page.route() ahead of the mount. Second, the glob actually matched — log route.request().url() once during development to confirm the pattern hits. Third, no request escaped: with the catch-all abort in place, a real network call throws, so a green run proves every request resolved to a stub. WebSocket traffic is a special case — page.route() covers HTTP, while live socket connections need the technique in simulating WebSocket connections in Playwright component tests.

The ordering guarantee is worth internalising as a sequence, because nearly every “the stub was ignored” bug is a violation of it. The route must be armed before the mount effect runs, or the fetch races past an unregistered handler and hits the network.

Correct ordering: register the route before mounting The route registers first, then the mount effect fires a fetch, the armed handler intercepts it and returns a stub, and the assertion sees resolved data instead of a loading state. Register first, then mount page.route() handler armed mount(<C/>) effect fires fetch intercept return stub JSON assert data, not Loading reverse the first two steps and the fetch escapes to the live network
Arm the route before the mount so the first fetch meets a handler, never the network.

Troubleshooting

When a stub is ignored, the usual cause is ordering: register the route before mounting. When only the first of several identical requests is stubbed, remember a page.route handler stays active for the whole context, but if you used route.fetch() inside it you may be re-hitting the network — guard against recursion. When a Service-Worker-based mock silently does nothing, that is expected: it is blocked in CT, so port the handlers to page.route. When responses arrive but the component still errors, check that route.fulfill sets the status and content type the component expects. When traffic still reaches production, your glob missed the real URL — broaden it to **/path and verify with a logged request URL.

FAQ

Why can’t I just reuse my MSW browser handlers in Playwright CT?

MSW’s browser mode relies on a Service Worker, and CT blocks Service Workers because their asynchronous registration lifecycle races the ephemeral per-spec page and duplicates the interception Playwright already does at the protocol level. Port the handler logic to page.route(), which intercepts every request deterministically from the test’s point of view. Your Node-side MSW setupServer handlers remain useful for jsdom and integration tests; only the in-browser Service Worker variant is unavailable here.

Does page.route intercept requests made by the component or by the test?

It intercepts requests issued by the browser page the component runs in — which is exactly the component’s own fetch and XMLHttpRequest traffic after mount. It does not see requests made by code running on a server, which is why server-side fetches in React Server Components need a different approach covered in the RSC guide. For client-side component fetches, page.route() is the complete answer.

How do I make a stubbed response deterministic across CI runs?

Never use random values or real timestamps inside a handler. Return fixed payloads, and if you simulate latency use a constant delay rather than Math.random(). Pair this with a catch-all abort so any unstubbed request fails the run instead of reaching the network. This gives byte-identical behavior on every machine, which is the foundation of stable runs and feeds directly into broader flaky-test mitigation.

What’s the difference between route.abort and route.fulfill with an error status?

route.abort('failed') simulates a transport-level failure — the fetch promise rejects, exercising your catch path, like a dropped connection or DNS failure. route.fulfill({ status: 500 }) returns a real HTTP response with an error status, so the request succeeds at the transport layer and your code must inspect response.ok. Test both, because components handle a rejected promise and a 500 body through different branches.