Skip to content

第 8 章 · 网络拦截与 Mock

本章目标:学会用 page.route 拦截任意网络请求,掌握 fulfill / continue_ / abort 三种处理方式,并能用 expect_response 精确等待特定 API 响应。

8.1 为什么 E2E 测试需要 Mock 网络

真实后端是 E2E 不稳定的主要来源:数据天天在变、第三方服务偶尔超时、边界场景(余额不足、支付失败)根本无法按需制造。Playwright 的网络拦截让你在浏览器层面接管请求——页面代码毫无感知,测试却能获得完全可控的数据。

python
from playwright.sync_api import sync_playwright, Route

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()

    def handle(route: Route):
        # 完全不发出真实请求,直接用 mock 数据响应
        route.fulfill(json=[{"name": "Strawberry", "id": 21}])

    page.route("**/api/v1/fruits", handle)   # 拦截匹配的请求
    page.goto("https://demo.playwright.dev/api-mocking")

    from playwright.sync_api import expect
    expect(page.get_by_text("Strawberry")).to_be_visible()
    browser.close()

URL 匹配支持三种写法:glob 字符串**/api/v1/**)、正则对象re.compile(r".*\/api\/v1\/fruits"))和 predicate 函数(接收 route.request 返回布尔值,可按方法、头信息精细过滤)。

8.2 三种处理方式:fulfill / continue_ / abort

拿到 route 后有三条路,覆盖了 Mock 测试的全部需求:

python
def handle(route: Route):
    req = route.request

    if req.method == "GET" and "banner" in req.url:
        # ① 直接伪造响应:请求根本不会到达服务器
        route.fulfill(status=200, json={"img": "/fake.png"})
    elif req.method == "POST":
        # ② 放行但篡改:补一个自定义请求头再继续发送
        headers = {**req.headers, "x-test-mark": "e2e"}
        route.continue_(headers=headers)
    else:
        # ③ 模拟网络失败:用于测试前端的错误处理 UI
        route.abort("failed")

route.continue_() 还能改 URL、method、postData;route.abort() 的参数是可选的错误码(如 "connectionrefused")。三者选一即可——对同一个 route 既 fulfill 又 continue 会抛异常。

注意 handler 必须有出口

每个被拦截的请求最终必须被 fulfill、continue_ 或 abort 之一处理。如果 handler 里条件分支漏掉了某种情况,对应请求会永远挂起,页面表现为"一直转圈",这是新手最常见的坑。

8.3 修改真实响应:fetch + fulfill 组合

更多时候你不想凭空造数据,而是想让真实 API 跑一遍、只修补结果(比如往列表里塞一条边界数据):

python
def handle(route: Route):
    response = route.fetch()            # 真实执行这次请求
    data = response.json()
    data.append({"name": "Loquat", "id": 100})   # 在真实数据上打补丁
    route.fulfill(response=response, json=data)  # 用修补后的内容响应

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()

这个模式非常适合测试"分页最后一页""列表为空""超长文本截断"这类依赖后端数据的场景。

8.4 等待与次数控制

等待特定响应:点击按钮触发了 XHR,断言又依赖它的结果时,用 expect_response 包裹动作:

python
# glob 匹配目标接口
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 True

限制路由生效次数page.route 从 v1.15 起支持 times 参数,让 mock 只命中前 N 次,之后的请求正常放行:

python
# 只有第一次请求返回失败,重试时走真实逻辑
page.route("**/api/payment", lambda r: r.abort(), times=1)
page.goto("https://myshop.example/checkout")

配合 page.unroute("**/api/payment") 可以在测试中途撤销拦截,两者组合足以表达绝大多数时序相关的网络场景。

8.5 实战:Mock 第三方支付网关

把前面所有技巧串起来——测试下单流程,但绝不让它真的碰支付服务:

python
import re

def test_checkout_with_mocked_payment(page):
    # 拦支付 SDK 脚本,避免加载外部 JS
    page.route(re.compile(r"https://pay-gateway\.example\.com/.*"),
               lambda r: r.fulfill(status=200, body="window.PayStubbed=true"))
    # 拦支付结果轮询接口,第一次"处理中",第二次"成功"
    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="去支付").click()
    expect(page.get_by_text("支付成功")).to_be_visible(timeout=10_000)

本章小结

  • page.route(url, handler) 在浏览器层拦截请求,支持 glob / 正则 / predicate 三种匹配;
  • fulfill 伪造响应、continue_ 放行或改写、abort 模拟故障,handler 必须三选一收尾;
  • route.fetch() + fulfill(response=response, ...) 实现"真实请求 + 响应修补";
  • expect_response 解决"断言依赖异步响应"的竞态;times=N 控制拦截生效次数。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. route 处理函数中,既想拿真实接口的数据又想追加一条记录,正确做法是?

2. 如果 route 处理函数的某个条件分支没有调用 fulfill/continue_/abort,会发生什么?

3. page.route 的 times 参数作用是什么?

4. 点击按钮触发 XHR,之后要断言响应数据渲染的结果,应该怎么做?

🛠️ 动手实践

  1. https://demo.playwright.dev/api-mocking 编写测试:mock fruits 接口返回空数组,断言页面出现"空列表"类提示。
  2. route.fetch() + fulfill 把某商品价格改成 0.01 元,走完加购流程验证订单金额。
  3. route.abort() 模拟静态图片全部加载失败,断言页面的降级占位图可见。

下一章:认证状态复用 storage_state