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.
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.
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.
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.
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.