跳转至

Playwright 实战:自动等待、拦截、Trace 与并行

Playwright 减少 flaky 的核心不是“更快的浏览器”,而是动作前做可操作性检查。理解这套机制,才能判断什么时候该等、什么时候是页面真的坏了。

1. 安装与 CI 依赖

pip install playwright pytest-playwright
playwright install --with-deps chromium

CI 里直接用官方镜像,省掉系统依赖:

container:
  image: mcr.microsoft.com/playwright/python:v1.47.0-jammy

版本要与 pip install playwright 的版本一致,否则报:

Executable doesn't exist at /ms-playwright/chromium-1140/chrome-linux/chrome

2. 自动等待到底等什么

每次 click() / fill() 前,Playwright 对目标元素做 actionability 检查:

attached → visible → stable(连续两帧位置不变)→ receives events(没被遮挡)→ enabled → editable

默认超时 30s(expect 默认 5s)。这意味着:

  • 大多数场景不需要手写 sleep
  • 但“元素存在”不等于“业务数据已返回”,接口数据渲染仍需等断言
# 等元素出现
page.get_by_role("button", name="提交").click()

# 等业务状态(推荐:用 expect 轮询)
from playwright.sync_api import expect
expect(page.get_by_test_id("order-status")).to_have_text("待支付")

不要用 page.wait_for_timeout(3000) 代替断言。固定等待在慢环境仍会挂,在快环境纯浪费。

3. 定位器:Role 优先,testid 兜底

page.get_by_role("button", name="提交订单").click()
page.get_by_label("收货地址").fill("北京市海淀区")
page.get_by_placeholder("请输入手机号").fill("13800000000")
page.get_by_test_id("pay-now").click()

严格模式报错:

Error: strict mode violation: get_by_role("button", name="删除") resolved to 3 elements

这不是 bug,是提示你定位不够精确。用 .nth(0) 或更具体的容器限定:

page.get_by_role("row", name="订单 20001").get_by_role("button", name="删除").click()

4. 登录态复用:只登录一次

# conftest.py
import pytest
from playwright.sync_api import Browser, Page

@pytest.fixture(scope="session")
def auth_state(browser: Browser, tmp_path_factory):
    state = tmp_path_factory.mktemp("auth") / "state.json"
    ctx = browser.new_context()
    page = ctx.new_page()
    page.goto(f"{BASE_URL}/login")
    page.get_by_label("用户名").fill(USER)
    page.get_by_label("密码").fill(PWD)
    page.get_by_role("button", name="登录").click()
    page.wait_for_url("**/dashboard")
    ctx.storage_state(path=str(state))
    ctx.close()
    return str(state)

@pytest.fixture
def page(browser: Browser, auth_state):
    ctx = browser.new_context(storage_state=auth_state)
    yield ctx.new_page()
    ctx.close()

价值:省掉每条用例 3-5 秒登录,并把“登录”这个不稳定点从所有用例里移走。注意 token 过期时间长于套件执行时间,否则中途集体 401。

5. 网络拦截:Mock 与故障注入

# 返回固定数据
page.route("**/api/orders", lambda route: route.fulfill(
    status=200,
    content_type="application/json",
    body='{"code":0,"data":[{"orderId":1,"status":"PAID"}]}'))

# 模拟 500
page.route("**/api/coupons", lambda route: route.fulfill(status=500, body="boom"))

# 模拟超时/断网
page.route("**/api/recommend", lambda route: route.abort("timedout"))

# 改写真实响应
def patch(route):
    resp = route.fetch()
    data = resp.json()
    data["data"]["amount"] = "0.01"
    route.fulfill(response=resp, json=data)

page.route("**/api/orders/*", patch)

常见错误:先点击后注册 route。请求已经发出,拦截不到。正确顺序是先 route,再触发动作。

6. 等接口:expect_response 的时序

with page.expect_response("**/api/orders") as info:
    page.get_by_role("button", name="提交").click()

resp = info.value
assert resp.status == 200
body = resp.json()
assert body["code"] == 0

expect_response 必须在触发动作之前进入上下文,否则会漏掉响应。

如果接口是轮询(每 2 秒一次),用 expect.poll 等业务状态更稳:

from playwright.sync_api import expect

expect.poll(lambda: page.request.get(f"{API}/orders/1").json()["data"]["status"],
            timeout=30_000).to_equal("PAID")

7. Trace:失败复盘的核心

pytest --tracing=retain-on-failure --output=test-results
# 或手动控制
context.tracing.start(screenshots=True, snapshots=True, sources=True)
...
context.tracing.stop(path="trace.zip")
playwright show-trace trace.zip

Trace 里能直接看到:每步操作前后的 DOM 快照、网络请求与响应、console 日志、源码位置。定位“为什么点不到”比截屏高效得多。

配置参考:

# pytest.ini
[pytest]
addopts =
    --tracing=retain-on-failure
    --screenshot=only-on-failure
    --video=retain-on-failure
    --output=test-results

8. 并行与分片

# 本地并行
pytest -n 4

# CI 分片:3 台机器各跑 1/3
# 用 pytest + pytest-playwright 时,pytest 没有 --shard,需装 pytest-split
pip install pytest-split
pytest --splits 3 --group 1
pytest --splits 3 --group 2
pytest --splits 3 --group 3

# 若直接用 Playwright 自带的 test runner(npx playwright test)
npx playwright test --shard=1/3

分片默认按用例数切,可能某片更重。pytest-split 可以先用 --store-durations 记录耗时,下次按 durations 文件均衡分配;Playwright runner 的 --shard 只按用例数均分,不读历史耗时,想均衡要靠自己拆分或调整用例分布。

并行注意:

  • 每个 worker 独立 browser_context,不要共享 page
  • 测试数据按 worker_id 隔离(同 pytest-xdist)
  • 视频与 trace 会显著增加磁盘 IO,CI 上只保留失败

9. 时区、语言与移动端

ctx = browser.new_context(
    locale="zh-CN",
    timezone_id="Asia/Shanghai",
    viewport={"width": 1440, "height": 900},
    user_agent="Mozilla/5.0 ...",
)

移动端:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    iphone = p.devices["iPhone 13"]
    browser = p.webkit.launch()
    ctx = browser.new_context(**iphone)

时区不设置时,CI 默认 UTC,涉及“当天”“本月”的用例会跨天失败——这是很常见的跨时区假失败。

10. 与 Selenium 的取舍

维度 Selenium Playwright
旧版浏览器 支持 不支持 IE
自动等待 需封装 内置
网络 Mock 需代理工具 原生
调试 截图/日志 Trace 全链路
多用户并发 多 driver 多 context,更轻
存量项目 已投入大 迁移成本高

新项目优先 Playwright;需要兼容旧浏览器或已有 Selenium 体系时,不必强迁。

11. 验收

□ 同一用例连跑 10 次全绿
□ 断网/接口 500 场景能被 Mock 稳定复现
□ 失败构建自动产出 trace,可直接 show-trace
□ 登录只执行一次(看耗时分布)
□ 4 并行下无账号/数据冲突
□ CI 时区固定为 Asia/Shanghai

相关阅读