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.
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.
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.
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.
Related
- Up to Playwright Component Testing — the mounting and configuration foundation.
- Testing React Server Components with Playwright — why server-side fetches escape browser routing.
- Using MSW to mock GraphQL endpoints locally — the Node-tier interception this complements.
- Simulating WebSocket connections in Playwright component tests — controlling socket traffic beyond HTTP routing.
- Flaky-test mitigation — eliminating nondeterminism that network stubs help remove.