A test that runs only when the staging API is up isn't a test — it's an integration check that depends on a third party's reliability. The same goes for tests that need the database in a particular state, or tests of UI flows that only happen when the API returns a 500. Network interception is how you decouple your tests from the backend: Playwright sits between the browser and the network, and you decide whether each request goes through, returns mock data, or fails. This lesson is about page.route — Playwright's intercept-and-modify primitive — and the four core operations you'll use it for.
What page.route does
page.route(urlPattern, handler) registers a handler that runs before every matching request leaves the browser. The handler receives a Route object and decides what to do: continue, fulfill (with mock data), or abort. If no route is registered, requests go through untouched as normal.
await page.route("**/api/products", async route => {
console.log("Intercepted:", route.request().method(), route.request().url());
await route.continue(); // let it go through to the real server
});
await page.goto("/products");
// Every request to /api/products is logged before being sentThree actions you can take inside the handler:
route.continue()— let the request proceed (optionally with modified headers/body).route.fulfill(...)— short-circuit with a mock response. The real server is never called.route.abort()— fail the request with a network error.
Plus a fourth, covered in the next lesson:
route.fetch()thenroute.fulfill(...)— let the request reach the real server, then modify the response before it gets to the app.
Spying — route.continue()
The simplest use case: log or assert on the requests your app makes, without changing them:
const requestUrls: string[] = [];
await page.route("**/api/**", async route => {
requestUrls.push(route.request().url());
await route.continue();
});
await page.goto("/dashboard");
await page.getByRole("button", { name: "Refresh" }).click();
// After the test runs, assert the app called the right endpoints
expect(requestUrls).toContain(expect.stringContaining("/api/orders"));
expect(requestUrls.length).toBeGreaterThan(2);This is what the team would call "spying" — the requests still happen, but you observe them. Useful for catching N+1 query bugs, missing-endpoint regressions, or analytics events that should fire on specific actions.
Mocking — route.fulfill()
The headline use case: replace the real response with one you control. Now you can test edge cases the backend can't easily produce:
await page.route("**/api/products", async route => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Mock Wireless Headphones", price: 29.99 },
{ id: 2, name: "Mock USB Cable", price: 9.99 }
])
});
});
await page.goto("/products");
await expect(page.getByText("Mock Wireless Headphones")).toBeVisible();The cleaner shorthand for JSON: pass json directly:
await page.route("**/api/products", async route => {
await route.fulfill({
status: 200,
json: [{ id: 1, name: "Mock product", price: 29.99 }]
});
});Mocking gives you complete control over the API surface — exact data, exact timing, exact status codes. The real server isn't called, so the test is fast, deterministic, and runs offline.
Mocking errors — testing the unhappy path
Most apps look polished when the API works. The bugs hide in the error states. Mock them:
test("shows a friendly error when products fail to load", async ({ page }) => {
await page.route("**/api/products", async route => {
await route.fulfill({
status: 500,
contentType: "application/json",
json: { error: "Internal server error" }
});
});
await page.goto("/products");
await expect(page.getByRole("alert")).toContainText("Something went wrong");
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});The same trick works for 401 (auth expired), 403 (permission), 404 (not found), 429 (rate limited), 503 (maintenance). Each one lets you write a deterministic test for a state the backend can't easily trigger.
Simulating slow APIs
Sometimes the bug is a UX bug — "the spinner doesn't show because the API is too fast." Slow the response down:
test("shows loading spinner during slow API call", async ({ page }) => {
await page.route("**/api/products", async route => {
await new Promise(resolve => setTimeout(resolve, 3000)); // 3-second delay
await route.fulfill({ status: 200, json: [] });
});
await page.goto("/products");
await expect(page.getByTestId("loading-spinner")).toBeVisible();
await expect(page.getByText("No products found")).toBeVisible({ timeout: 5_000 });
});Combine this with route.fulfill({ status: 504 }) after a delay to test "spinner → timeout error" flows.
Aborting — testing offline scenarios
route.abort() makes the request fail with a network error, which is what happens when the user goes offline mid-flow:
test("handles network failure gracefully", async ({ page }) => {
await page.route("**/api/orders", route => route.abort());
await page.goto("/orders");
await expect(page.getByRole("alert")).toContainText("Connection failed");
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
});A common performance trick is aborting all images and fonts to make tests faster — the page logic runs identically without them:
await page.route("**/*.{png,jpg,jpeg,gif,webp,svg,woff,woff2}", route => route.abort());Use this in beforeEach or globally in playwright.config.ts for a 30-50% speedup on image-heavy pages, with no impact on functional assertions.
URL patterns — glob, regex, predicate
Three ways to match URLs:
// Glob — most readable, * = anything except /, ** = anything including /
await page.route("**/api/products", handler);
await page.route("**/api/users/*", handler); // matches /api/users/42
await page.route("**/*.json", handler);
// Regex — for complex patterns
await page.route(/\/api\/products\?page=\d+/, handler);
// Predicate function — for conditional matching
await page.route(
url => url.pathname.startsWith("/api/") && url.searchParams.get("admin") === "true",
handler
);Glob is the default. Regex when you need character-class precision. Predicate when the matching depends on parsed parts of the URL.
Three flavours of route handling
Continue, fulfill, abort — three things a Route can do
continue() — let it through
Real request goes to the server
Real response comes back to the app
Use for spying — log/assert on the requests
Or modify headers/body before forwarding
fulfill() — return mock data
Real server is NOT called
App receives whatever you put in the response
Test edge cases: empty arrays, errors, slow responses
Fully deterministic — runs offline
abort() — kill the request
Request fails with a network error
Use for: offline scenarios, blocking analytics, blocking images
Optional reason: 'connectionrefused', 'timedout'
Lets you test 'connection lost' UI without unplugging the network
Set the route BEFORE the action
The single most common mistake: registering a route after the request has already fired. Routes only intercept requests fired after the route is registered. So:
// ❌ Wrong — products are already loaded by the time the route is set
await page.goto("/products");
await page.route("**/api/products", route => route.fulfill({ json: [] }));
// ✅ Right — route first, then the action that triggers the request
await page.route("**/api/products", route => route.fulfill({ json: [] }));
await page.goto("/products");Set every route before the goto (or before the click that triggers the request). For routes that should only apply to one specific action, set up the route just before that action.
Removing routes — page.unroute()
When a test needs different mocking behaviour after some point:
// First half of the test — empty list
await page.route("**/api/products", route => route.fulfill({ json: [] }));
await page.goto("/products");
await expect(page.getByText("No products")).toBeVisible();
// Now switch to populated list for the second half
await page.unroute("**/api/products");
await page.route("**/api/products", route => route.fulfill({ json: [{ id: 1, name: "Headphones" }] }));
await page.reload();
await expect(page.getByText("Headphones")).toBeVisible();For most tests, you set routes once at the top and never unroute — but this escape hatch matters for tests that explicitly verify "the second call returns different data."
Coming from Cypress?
The mappings:
cy.intercept('GET', '/api/x', { fixture: 'x.json' })→await page.route('**/api/x', route => route.fulfill({ json: fixtureData }))cy.intercept('POST', '/api/x', { statusCode: 500 })→await page.route('**/api/x', route => route.fulfill({ status: 500 }))cy.intercept('/api/x').as('xx'); cy.wait('@xx')→const responsePromise = page.waitForResponse('**/api/x'); /* trigger */; await responsePromise
The big difference: Cypress's cy.intercept matches by HTTP verb by default (GET, POST). Playwright's page.route matches all methods unless you check inside the handler:
await page.route("**/api/products", async route => {
if (route.request().method() === "POST") {
await route.fulfill({ status: 201, json: { id: 42 } });
} else {
await route.continue();
}
});For most teams the migration mainly means: set routes earlier (Cypress let you intercept after-the-fact in some cases; Playwright doesn't) and use route.fulfill instead of fixture aliases.
⚠️ Common mistakes
- Setting the route after the request was already made. Routes only intercept future requests. If the page loaded products before you registered the route, the route never fires. Order is: route first, action second.
- Forgetting to
awaitinside the handler.page.route('...', route => route.fulfill({ ... }))(noasync/await) leaves a floating promise. The handler returns synchronously, the framework warns, the test sometimes flakes. Mark the handlerasyncandawait route.fulfill(...). - Mocking everything, including auth and analytics, by accident. A wide pattern like
page.route('**/api/**', ...)catches more than you mean. Be specific (**/api/products) or use a predicate to allow auth/analytics traffic through. The right policy is to mock only what the test depends on; let everything else go through.
🎯 Practice task
Build a route-aware spec that mocks four scenarios. 30 minutes.
-
Pick any small app you control (or a public sandbox like JSONPlaceholder at
https://jsonplaceholder.typicode.com). Set thebaseURLaccordingly. -
Create
tests/route-mocking.spec.tswith four tests:import { test, expect } from "@playwright/test"; test.describe("Network interception with route", () => { test("mock — empty list shows empty state", async ({ page }) => { await page.route("**/posts", route => route.fulfill({ status: 200, json: [] }) ); await page.goto("https://jsonplaceholder.typicode.com/posts"); const text = await page.locator("body").textContent(); expect(text).toBe("[]"); }); test("mock — return one specific post", async ({ page }) => { await page.route("**/posts/1", route => route.fulfill({ status: 200, json: { id: 1, title: "MOCKED TITLE", body: "Hello from Playwright" } }) ); await page.goto("https://jsonplaceholder.typicode.com/posts/1"); await expect(page.locator("body")).toContainText("MOCKED TITLE"); }); test("error — 500 response", async ({ page }) => { await page.route("**/posts/1", route => route.fulfill({ status: 500, contentType: "text/plain", body: "Server error" }) ); const responsePromise = page.waitForResponse("**/posts/1"); await page.goto("https://jsonplaceholder.typicode.com/posts/1"); const response = await responsePromise; expect(response.status()).toBe(500); }); test("abort — block image requests for speed", async ({ page }) => { const blocked: string[] = []; await page.route("**/*.{png,jpg,jpeg,gif,webp,svg}", route => { blocked.push(route.request().url()); return route.abort(); }); await page.goto("https://www.saucedemo.com/v1/inventory.html", { waitUntil: "domcontentloaded" }).catch(() => {}); // Even if the goto auth-fails, the abort still recorded any image attempts expect(blocked.length).toBeGreaterThanOrEqual(0); }); }); -
Run all four tests across all three browsers. The first three demonstrate
fulfillfor happy, custom, and error paths; the fourth demonstratesabortfor performance. -
Demonstrate the late-route bug. In the second test, move the
await page.route(...)line to after thegoto. Run again — the mock no longer applies; you get the real response. Move it back. This is the muscle memory: route first, action second. -
Stretch: add a fifth test that uses a predicate matcher to only mock POST requests to
/posts, leaving GETs alone. The handler should checkroute.request().method()and eitherfulfill(for POST) orcontinue(for GET). This is the pattern for testing form submission paths in real apps without affecting the read-only paths.
You now own three quarters of network mocking — continue, fulfill, abort. The next lesson covers the fourth: fetch + fulfill, the pattern for modifying a real response on its way through to the app. That's the one you'll reach for when "almost-real data" beats "fully synthetic data."