Chapter 8 · Network Interception and Mock
Chapter goals: Learn to intercept any network request with
page.route, master the three handling strategies — fulfill / continue_ / abort — and precisely wait for specific API responses withexpect_response.
8.1 Why E2E Tests Need Network Mocking
Real backends are the main source of E2E instability: data changes daily, third-party services time out occasionally, and edge cases (insufficient balance, payment failure) are impossible to trigger on demand. Playwright's network interception lets you take over requests at the browser level — the page code is unaware, and the test gets fully controllable data.
from playwright.sync_api import sync_playwright, expect
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
def handle(route):
# Never send a real request; respond with mock data directly
route.fulfill(json=[{"name": "Strawberry", "id": 21}])
page.route("**/api/v1/fruits", handle) # Intercept matching requests
page.goto("https://demo.playwright.dev/api-mocking")
expect(page.get_by_text("Strawberry")).to_be_visible()
browser.close()URL matching supports three forms: glob string ("**/api/v1/**"), regex object (re.compile(r".*\/api\/v1\/fruits")), and predicate function (receives route.request, returns bool — can filter by method, headers, etc.).
8.2 Three Handling Strategies: fulfill / continue_ / abort
After receiving a route, you have three paths covering all mock test needs:
def handle(route):
req = route.request
if req.method == "GET" and "banner" in req.url:
# ① Forge response directly: request never reaches the server
route.fulfill(status=200, json={"img": "/fake.png"})
elif req.method == "POST":
# ② Forward with modification: inject a custom header and continue
headers = {**req.headers, "x-test-mark": "e2e"}
route.continue_(headers=headers)
else:
# ③ Simulate network failure: for testing frontend error-handling UI
route.abort("failed")route.continue_() can also mutate URL, method, and postData; route.abort() takes an optional error code (e.g. "connectionrefused"). Pick exactly one per route — calling both fulfill and continue_ on the same route raises an exception.
Every handler must have an exit path
Every intercepted request must eventually be handled by fulfill, continue_, or abort. If a condition branch in your handler misses a case, that request hangs forever and the page appears to "keep spinning" — the most common beginner pitfall.
8.3 Modifying Real Responses: fetch + fulfill
More often you don't want to fabricate data from scratch — you want the real API to run, then patch the result (e.g., append an edge-case item to the list):
def handle(route):
response = route.fetch() # Execute the real request
data = response.json()
data.append({"name": "Loquat", "id": 100}) # Patch onto real data
route.fulfill(response=response, json=data) # Respond with patched content
page.route("**/api/v1/fruits", handle)
page.goto("https://demo.playwright.dev/api-mocking")
expect(page.get_by_text("Loquat", exact=True)).to_be_visible()This pattern is ideal for testing scenarios like "last page of pagination", "empty list", or "超长文本截断" that depend on backend data shapes.
8.4 Waiting and Call Count Control
Waiting for a specific response: when a button click triggers an XHR and your assertion depends on its result, wrap the action with expect_response:
# Glob-match the target endpoint
with page.expect_response("**/api/fetch_data") as response_info:
page.get_by_text("Update").click()
response = response_info.value
assert response.status == 200
assert response.json()["ok"] is TrueLimiting route hit count: page.route supports a times parameter since v1.15, making the mock fire only for the first N hits, then letting subsequent requests pass through normally:
# Only the first request fails; retries go to the real logic
page.route("**/api/payment", lambda r: r.abort(), times=1)
page.goto("https://myshop.example/checkout")Combined with page.unroute("**/api/payment") you can revoke interception mid-test; together they express most time-sensitive network scenarios.
8.5 Real-World: Mocking a Third-Party Payment Gateway
Stringing all techniques together — test the checkout flow without ever touching the real payment service:
import re
def test_checkout_with_mocked_payment(page):
# Block the payment SDK script to avoid loading external JS
page.route(re.compile(r"https://pay-gateway\.example\.com/.*"),
lambda r: r.fulfill(status=200, body="window.PayStubbed=true"))
# Mock the payment status polling: first "pending", then "paid"
states = iter([{"state": "pending"}, {"state": "paid"}])
def poll(route):
route.fulfill(json=next(states))
page.route("**/api/pay/status", poll)
page.goto("https://myshop.example/checkout")
page.get_by_role("button", name="Pay").click()
expect(page.get_by_text("Payment successful")).to_be_visible(timeout=10_000)8.6 Chapter Summary
page.route(url_pattern, handler)intercepts requests at the browser level;- Three strategies:
fulfill(fake response),continue_(forward, optionally modified),abort(simulate failure); - Every handler must have an unconditional exit path — missing a branch hangs the page;
route.fetch() + fulfillpatches real API responses;expect_responsewaits for specific API calls;times=Nlimits mock hit count.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. What happens if a route handler does not call fulfill/continue_/abort?
2. To modify headers on a real request before it reaches the server, which method do you use?
3. What does route.fetch() do?
4. How do you make a mock route only fire for the first 3 requests?
🛠️ Hands-On Exercises
- Intercept a real API call on any public site with
page.route; log the original response status and body before fulfilling with modified data. - Write a test that uses
route.abort("connectionrefused")on a specific endpoint and asserts the frontend shows an error banner. - Use
expect_responseto capture a XHR and assert both its status code and response body shape in one test.
Proceed to Chapter 9: Auth State Reuse with storage_state.