Real apps make network calls — for products, users, orders, search results. When you test the UI, those calls go to a real (or test) backend by default. Sometimes that's exactly what you want; sometimes it's the slowest, flakiest part of the test. Playwright's page.route lets you intercept any request and decide what happens to it: forward it, mock it, modify the response, abort it. This is the API that turns a flaky integration test into a deterministic UI test against a controlled fixture, and it's the same engine the Playwright TypeScript course uses, with snake_case naming and one notable Python-specific quirk: route.continue_() has a trailing underscore because continue is a reserved word.
The basic pattern — a handler per route
page.route(url_pattern, handler) registers a callback that runs every time a request matches the pattern. The handler receives a Route object and decides what to do:
def handle_route(route):
print(f"Request: {route.request.url}")
route.continue_() # let it through unchanged
page.route("**/api/products", handle_route)Three things to know:
- The URL pattern uses glob syntax —
**/api/productsmatcheshttps://myapp.com/api/productsandhttp://localhost/api/productsand any other prefix. - The handler must do something — call
route.fulfill(),route.continue_(), orroute.abort(). Forgetting to call any of them hangs the request until the action timeout fires. route.continue_()has a trailing underscore.continueis a Python reserved word, so the method name needs the suffix. This is the one place the API differs from the TypeScript version (route.continue()).
Spying on traffic — let it through, log it
The simplest use: pass-through with logging, useful when you want to see what the page actually requests:
def log_request(route):
print(f"{route.request.method} {route.request.url}")
route.continue_()
page.route("**/api/**", log_request)
page.goto("/products")Every API call gets printed; the page behaves identically to a normal run. Use this when debugging flaky tests where you suspect a missing request or a malformed query string.
Mocking a response — route.fulfill
To return canned data instead of letting the request through:
import json
def mock_products(route):
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps([
{"id": 1, "name": "Mock Product", "price": 29.99},
{"id": 2, "name": "Another Mock", "price": 49.99},
])
)
page.route("**/api/products", mock_products)
page.goto("/products")
expect(page.get_by_test_id("product-card")).to_have_count(2)The page never hits the real API — fulfill returns a synthetic response. This is the right tool for testing UI states that are hard to reproduce against a real backend: "what does the empty list look like?", "what happens with 1000 items?", "what if every product has a 200-character name?".
For JSON specifically, you can pass json=... instead of stringifying yourself:
page.route("**/api/products", lambda route: route.fulfill(json=[]))json= automatically sets content_type to application/json and serialises the value. Cleaner for the common case.
Mocking errors — testing the unhappy path
What does the UI do when the API returns 500? Fulfill it:
page.route("**/api/products", lambda route: route.fulfill(
status=500,
body="Server Error"
))
page.goto("/products")
expect(page.get_by_text("Couldn't load products")).to_be_visible()
expect(page.get_by_role("button", name="Retry")).to_be_visible()Same shape for 401, 403, 404, 502, 503 — just change the status. Without route mocking you'd need to stand up a fault-injection backend; with it, every error case is one line.
Adding latency — testing slow networks
The route handler is just a Python function — sleep before fulfilling and you've simulated a slow API:
import time
def slow_response(route):
time.sleep(3)
route.fulfill(status=200, json=[])
page.route("**/api/products", slow_response)
page.goto("/products")
expect(page.get_by_test_id("loading-spinner")).to_be_visible()
expect(page.get_by_text("No products found")).to_be_visible(timeout=10_000)Three-second delay lets you assert the spinner appears, then assert the empty state renders once the response lands. The default expect timeout (5s) won't fire because we override it to 10s. For broader latency simulation, Playwright also exposes context-level network conditions, but time.sleep in the route handler is enough for most UI tests.
Modifying real responses — route.fetch then route.fulfill
Sometimes you want the real backend's data with one field tweaked — long names for layout testing, malformed dates for parsing edge cases, missing optional fields. The pattern is fetch-then-mutate:
def modify_response(route):
response = route.fetch()
body = response.json()
body[0]["name"] = "MODIFIED: " + body[0]["name"]
route.fulfill(response=response, json=body)
page.route("**/api/products", modify_response)route.fetch() forwards the request and returns the real response. You read its JSON, mutate, and fulfill with the modified body. The response=response argument carries through headers and status from the real response, so the page sees a consistent envelope.
This is the approach that scales better than full mocks — you don't have to maintain a fake of the whole API, just override the one thing your test cares about.
Aborting requests — block resources you don't need
Tests don't usually care about images, fonts, or analytics pixels. Block them to speed up the run:
page.route("**/*.{png,jpg,jpeg,svg,woff2}", lambda route: route.abort())
page.route("**/analytics.js", lambda route: route.abort())
page.route("**/*googletagmanager*", lambda route: route.abort())route.abort() ends the request as a network failure. The page sees the request fail; if it's a non-essential resource (image, analytics script), the page renders fine without it. Typical savings on a media-heavy page: 1-2 seconds per test, several megabytes of bandwidth.
Don't blindly abort everything that ends in .js — your app's own bundles need to load. Be specific.
When to use a lambda vs a named handler
Simple mocks fit on one line and read fine inline:
page.route("**/api/health", lambda route: route.fulfill(json={"ok": True}))
page.route("**/api/products/*", lambda route: route.abort())Anything multi-line or with logic, name the function:
def conditional_mock(route):
if "search" in route.request.url:
route.fulfill(json=[])
else:
route.continue_()
page.route("**/api/**", conditional_mock)The named-function form gives stack traces that point at the right handler when something goes wrong, plus reusability across tests. Lambdas are pure brevity for the trivial cases.
Removing a route — page.unroute
Routes are scoped to the page (or context, if you use context.route). They tear down automatically when the context closes — i.e., at the end of each test by default. To remove one mid-test:
page.unroute("**/api/products")Useful when you want one part of the test to use a mock and the next part to hit the real API.
Real call vs mocked call vs aborted call
What route.continue_, route.fulfill, and route.abort actually do
route.continue_() — pass-through
The request goes to the real backend unchanged
Use to spy/log requests, or to selectively forward in a conditional handler
Note the trailing underscore — continue is a Python reserved word
Optionally modify the request first via route.continue_(headers=..., post_data=...)
route.fulfill(...) — mock response
The page sees a synthetic response — never reaches the backend
Pass status, json/body, headers, content_type
Right tool for testing edge cases: empty list, 500 error, malformed data
Use json=... to skip stringifying yourself for JSON responses
route.abort() — fail the request
The request fails as a network error from the page's perspective
Right tool for blocking non-essential resources (images, analytics)
Also useful to test how the UI handles network failures
Optionally pass an error reason: route.abort('failed', 'addressunreachable')
Coming from Playwright TypeScript?
Mappings:
await page.route('**/api/products', route => route.fulfill({ json: [] }))→page.route("**/api/products", lambda route: route.fulfill(json=[]))await route.continue()→route.continue_()(note underscore)await route.fetch()→route.fetch()(no await)await route.fulfill({ status, body, json, headers })→route.fulfill(status=..., body=..., json=..., headers=...)(kwargs)await page.unroute(...)→page.unroute(...)
Same engine, same matching logic, identical capabilities. The trailing underscore on continue_ is the one thing that catches every developer the first time.
⚠️ Common mistakes
- Forgetting the trailing underscore on
continue_.route.continue()raisesSyntaxError: invalid syntaxbecausecontinueis a Python reserved word. Pylance/mypy catches it at edit time; Python catches it at parse time. The fix is mechanical — just add the underscore. - Registering the route after navigating.
page.goto("/products")followed bypage.route(...)is too late — the API call already fired. Register the route before the navigation that triggers the request:page.route(...)thenpage.goto(...). - Forgetting to handle the route at all. A route handler that doesn't call
continue_,fulfill, orabortleaves the request hanging until the action timeout. The error is opaque:Test ended due to action timeout, no response received. Always end the handler with one of the three terminal calls.
🎯 Practice task
Mock a real product API and verify the UI renders the mocked data. 30 minutes.
-
Use any public app that lists products (or your own dev environment). For practice, we'll mock against Sauce Demo's inventory page even though it doesn't make a real API call — the assertions still verify the mocking mechanism works.
-
Create
tests/test_route_mocking.py:import json from playwright.sync_api import Page, expect def test_mock_empty_state(page: Page): def mock_empty(route): route.fulfill(status=200, json=[]) page.route("**/api/products", mock_empty) page.goto("https://api-demo.example.com/products") expect(page.get_by_text("No products found")).to_be_visible() def test_mock_server_error(page: Page): page.route("**/api/products", lambda route: route.fulfill(status=500, body="DB down")) page.goto("https://api-demo.example.com/products") expect(page.get_by_text("Couldn't load products")).to_be_visible() def test_block_images_for_speed(page: Page): page.route("**/*.{png,jpg,jpeg}", lambda route: route.abort()) page.goto("/") # Page still loads; images don't expect(page.get_by_placeholder("Username")).to_be_visible() -
Run with
pytest tests/test_route_mocking.py -v --headedand watch the network tab — confirm no real product calls go out. -
Add a logging spy. Add a fourth test that uses
route.continue_()and prints every request URL. Run with-sto see the prints. Confirm the spy logs all the requests but the page still works exactly as before. -
Modify a real response. Pick any app that returns JSON (a public API like
https://jsonplaceholder.typicode.com/posts) and write a test that fetches the real response, mutates the first post's title, and fulfills with the modified body. Assert the page (orroute.fetch().json()) reflects the modified data. -
Stretch: add a route that simulates 2 seconds of latency, and write a test that asserts the loading spinner is visible during the delay. Use
time.sleep(2)in the handler. Useexpect(...).to_be_visible(timeout=5000)for the spinner.
You've got the request-interception toolkit. The next lesson covers the other half of network testing — Playwright's request fixture for direct API testing, no browser involved, plus the patterns for combining API-driven setup with UI-driven assertions.