Network Control in Playwright — Mock, Abort, and Fulfill
Use Playwright routing to fulfill, abort, or pass through calls so a UI test fails for the reason you intended.
This post is part of the Playwright Essentials series. If you missed the previous post, read Part 10 — Playwright Practices That Survive Growth first.
Introduction — The UI Test Is Waiting on Someone Else
A checkout test fails because inventory returned 503. The assertion never runs. The report says the pay button was missing. The button was missing because the price never arrived.
Playwright can answer the network itself. You decide which calls are part of the behaviour under test and which calls are scenery.
Three Answers to a Request
page.route runs before the request leaves the browser.
- fulfill — you supply status, headers, and body. The application sees a normal response.
- abort — the request fails in the browser. Use this for offline, dropped calls, and “the script did not load.”
- continue / fallback — the request goes to the real server. Use this for everything you did not mean to own.
Register the specific route first. Playwright uses the last matching route, so a catch-all registered first and a specific route registered second is the usual shape.
await page.route('**/api/**', (route) => route.fallback());
await page.route('**/api/inventory/*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ sku: 'book-1', available: 0 }),
}),
);
await page.goto('/checkout');
await expect(page.getByRole('button', { name: 'Pay' })).toBeDisabled();
await expect(page.getByText('Out of stock')).toBeVisible();
The assertion is about the UI. The mock exists so the UI has a known reason to be in that state.
Do Not Mock the Thing You Claim to Test
If the test is “paying calls the payments service,” the payments call must leave the test. Mock inventory, tax, and the feature-flag service if they are not the subject.
If the test is “the pay button stays disabled when inventory is empty,” mock inventory and let the button be the subject.
A test that mocks every call and then asserts that a heading is visible is a screenshot of your stubs.
Failures You Can Name
test('shows a retry when pricing times out', async ({ page }) => {
await page.route('**/api/pricing', (route) => route.abort('timedout'));
await page.goto('/cart');
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
});
Abort reasons include timedout, failed, and internetdisconnected. Pick the one the product actually handles. If the product treats all of them as a generic toast, one test is enough.
HAR and Recorded Traffic
routeFromHAR replays a recorded file. It is useful when a third party has no test double and the response is large. Update the HAR when the contract changes. A year-old HAR that still returns 200 is how a redesigned API ships with a green suite.
Prefer a small fulfill body you wrote for the case. You can see the scenario in the test. A HAR hides it.
Conclusion
Control the calls that would make the test about something else. Let the call you care about through. Assert the state the user sees, not the fact that your stub returned 200.
Action for this week: Find one UI test that failed last month because a dependency was down. Route that dependency. Leave the assertion on the screen the user was supposed to see.
Next in the series: Part 12 — Which Browsers Actually Earn a Test Run.