04 · Network Interception & Mocking¶
Why intercept network traffic in a test¶
Real end-to-end tests should hit a real backend most of the time — that's the point of E2E. But some scenarios are impractical to trigger honestly: a 500 error from a payment provider, a slow/flaky third-party API, a response with data that doesn't exist yet in your test environment. Playwright can intercept, inspect, modify, or fully replace any network request the page makes, at the browser level, with no proxy setup needed.
Reading requests and responses: page.on¶
from playwright.sync_api import Page
def test_search_calls_expected_api(page: Page):
requests = []
page.on("request", lambda req: requests.append(req.url))
page.goto("https://example.com/search")
page.get_by_placeholder("Search").fill("laptop")
page.get_by_placeholder("Search").press("Enter")
page.wait_for_response(lambda r: "/api/search" in r.url)
assert any("/api/search?q=laptop" in url for url in requests)
page.on("request", ...) and page.on("response", ...) are passive
observers — good for asserting that a call happened, with what
parameters, without changing anything about how the app behaves.
wait_for_response for precise synchronization¶
with page.expect_response(lambda r: "/api/search" in r.url and r.status == 200) as resp_info:
page.get_by_placeholder("Search").press("Enter")
response = resp_info.value
data = response.json()
assert len(data["results"]) > 0
This is more reliable than an arbitrary page.wait_for_timeout(1000) after
pressing Enter: the test waits for the specific network event that
proves the search actually completed, not an arbitrary clock duration that
either wastes time or isn't long enough on a slow CI runner.
Mocking a response with page.route¶
import json
from playwright.sync_api import Page
def test_search_with_no_results(page: Page):
def handle_search(route):
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"results": []}),
)
page.route("**/api/search**", handle_search)
page.goto("https://example.com/search")
page.get_by_placeholder("Search").fill("nonexistent-item-xyz")
page.get_by_placeholder("Search").press("Enter")
from playwright.sync_api import expect
expect(page.get_by_text("No results found")).to_be_visible()
page.route(pattern, handler) intercepts every request matching the glob
pattern before it reaches the network. Calling route.fulfill(...)
short-circuits it entirely — the real backend is never called — letting
you deterministically test the empty-state UI without needing a backend
that actually returns zero results for some magic query string.
Simulating errors and slow networks¶
def test_search_shows_error_banner_on_500(page: Page):
page.route(
"**/api/search**",
lambda route: route.fulfill(status=500, body="Internal Server Error"),
)
page.goto("https://example.com/search")
page.get_by_placeholder("Search").fill("laptop")
page.get_by_placeholder("Search").press("Enter")
from playwright.sync_api import expect
expect(page.get_by_text("Something went wrong. Try again.")).to_be_visible()
import time
def test_shows_loading_spinner_while_search_pending(page: Page):
def slow_response(route):
time.sleep(2) # simulate backend latency
route.continue_()
page.route("**/api/search**", slow_response)
page.goto("https://example.com/search")
page.get_by_placeholder("Search").press("Enter")
from playwright.sync_api import expect
expect(page.get_by_role("status", name="Loading")).to_be_visible()
This is the only realistic way to test a 500-error banner or a loading spinner deterministically — you cannot reliably make a real backend fail or lag on demand, but you can always make the browser believe it did.
Modifying a real response instead of replacing it¶
def test_feature_flag_forced_on(page: Page):
def add_feature_flag(route):
response = route.fetch()
body = response.json()
body["features"]["new_checkout"] = True
route.fulfill(response=response, json=body)
page.route("**/api/config", add_feature_flag)
page.goto("https://example.com")
# no output — route.fetch() performs the real network request,
# then fulfill(response=..., json=...) replays it with one field
# patched, leaving everything else (headers, other fields) intact
route.fetch() followed by route.fulfill(response=...) is the pattern
for "mostly real, one field different" — safer than hand-writing a full
mock body that will silently drift out of sync with the real API shape.
Stopping unwanted third-party calls¶
# no output — route.abort() fails the request outright; useful
# to keep tests fast and to avoid polluting real analytics with
# test traffic every CI run generates
How It Actually Works¶
page.route() is built on CDP's Fetch domain, not a browser-external
proxy. Registering a route sends Fetch.enable with a URL pattern filter to
the browser; from that point on, the browser itself pauses any matching
request at the network layer and emits a Fetch.requestPaused event to the
driver instead of letting the request continue — the actual HTTP request
hasn't even left the browser process yet at this point. Your Python handler
runs, and depending on what it calls, the driver sends back one of three
different Fetch-domain commands: route.continue_() maps to
Fetch.continueRequest (let it proceed, optionally with modified
headers/method/body), route.fulfill() maps to Fetch.fulfillRequest
(never let the request reach the network at all — hand back a fabricated
response directly), and route.abort() maps to Fetch.failRequest (fail it
with a specific network error code).
This is why mocking works with zero proxy setup and zero app code changes:
the interception happens inside the browser process itself, at the same
layer CDP's network domain observes real traffic, so it's indistinguishable
to the page's JavaScript from a real network response arriving. route.fetch()
followed by route.fulfill(response=...) is the one path that does perform
a real network round trip — fetch() here issues an actual HTTP request
(outside the paused one) to get a genuine response object your handler can
then mutate before finally resolving the paused request with
Fetch.fulfillRequest using your edited body. page.on("request"/"response")
listeners are simpler — they subscribe to Network.requestWillBeSent /
Network.responseReceived events, which fire for observation only and
carry no ability to pause or alter anything, which is exactly why they're
positioned as passive/read-only in contrast to route().
Exercise¶
Using https://httpbin.org as a stand-in backend and any simple page you
control (or https://the-internet.herokuapp.com/):
- Write a test that navigates to a page making an XHR/fetch call, use
page.on("request", ...)to log every request URL, and assert the page makes at least one request containing/getor/api. - Use
page.routeto intercept a request to**/get**androute.fulfill()a fixed JSON body of your choosing; reload the page and assert the mocked data renders instead of the real response. - Use
route.abort()to block a specific request pattern and assert the page shows a fallback/error state instead of hanging. - Combine
page.expect_response(...)with an action to assert a specific request fires with the correct query string when a filter/search input is used — without mocking anything, just observing. - Write
route.fetch()+route.fulfill(response=..., json=patched_body)against a realhttpbin.org/getcall, patching one key in the returned JSON, and assert the patched value appears in the page (e.g. by writing a tiny HTML page that fetches and renders it, or by asserting viaresponse.json()directly in the test).