一条 CI 流水线,端到端测试二十个用例,每次总有两三个失败。点开日志,报错是「元素不可点击」,重跑一遍又全绿。改了两周,把 sleep 从 1 秒加到 3 秒,失败率降下来了,整条流水线也从 8 分钟变成 15 分钟。
这类问题的根源不是产品有 bug,而是测试脚本在猜「页面好了没有」。Playwright 的出发点就是把这件事从「猜」变成「等一个明确条件」。
稳的原因:等待被写进了动作里#
Selenium 里点一个按钮,脚本执行完 click() 就继续往下走,页面没准备好是脚本的事。Playwright 的每个动作之前会做一组可操作性检查:元素存在、可见、尺寸稳定、没有被禁用、没有被别的元素遮挡。任意一项不满足就重试,直到超时。
断言也一样。expect(locator).to_be_visible() 不是查一次就返回,而是在超时时间内反复求值,条件成立立刻通过。这叫 web-first assertion,它替代了绝大多数手写的显式等待。
另外两个设计也直接减少 flaky:
- 每个用例一个浏览器上下文。cookie、localStorage、缓存天然隔离,并行跑不会互相污染,比开一堆浏览器进程便宜得多。
- 失败现场自动留存。trace 里记录了 DOM 快照、网络请求、console 输出和每一步的截图,事后能像回放一样看。
这三件事合起来的效果是可以量的:固定 sleep 的耗时随用例数线性增长,条件等待只在真正慢的那几步上花钱。验证方法很简单,同一条用例跑十次,看耗时分布而不是平均值——平均值会被最慢的一次拉偏,而 flaky 恰恰藏在尾部。
一个能跑的最小例子#
安装方式(pip、npm、包管理器)以官方文档为准,装完还要下载浏览器二进制,这一步别漏。
# pip install pytest-playwright && playwright install chromium
from playwright.sync_api import Page, expect
def test_login(page: Page):
page.goto("https://example.com/login")
page.get_by_label("邮箱").fill("user@example.com")
page.get_by_label("密码").fill("not-a-real-password")
page.get_by_role("button", name="登录").click()
expect(page.get_by_role("heading", name="控制台")).to_be_visible()
注意这里没有任何 sleep,也没有 find_element 加 WebDriverWait 的组合。定位用的是角色和标签文本,不是 CSS 路径。
失败时抓 trace:
pytest --tracing=retain-on-failure
playwright show-trace test-results/*/trace.zip
retain-on-failure 只在失败时保留,不会把每次运行的产物都堆在磁盘上。
定位器怎么选,决定脚本能活多久#
优先级从高到低,理由都是「抗重构」:
| 优先级 | 定位方式 | 适用 |
|---|---|---|
| 1 | get_by_role |
按钮、链接、标题等语义元素,最稳 |
| 2 | get_by_label |
表单字段,跟着 label 走 |
| 3 | get_by_placeholder |
无 label 的输入框 |
| 4 | get_by_text |
静态文案,注意文案会改 |
| 5 | get_by_test_id |
前四者都不合适时,让前端加 data-testid |
| 6 | CSS / XPath | 最后选择,耦合 DOM 结构 |
用 get_by_role("button", name="登录") 的脚本,前端把 div 换成 button、把类名全改掉,测试都不用动。用 div:nth-child(3) > span 的脚本,一次样式重构就全红。
稳定性与迁移#
Playwright 给了机制,但下面几件事仍然要靠人:
- 别用
wait_for_timeout。它是sleep换了名字,一旦出现就说明有个条件没找对。要等 URL 变化用wait_for_url,要等接口用wait_for_response,要等界面用expect。 - 登录态复用要管过期。
storage_state存下来能省掉每个用例重新登录,但 token 过期后会得到一堆莫名其妙的失败。CI 里每次运行重新生成,或者给失败用例加重试一次。 - 测试数据自己造。依赖「数据库里恰好有一条 id=1 的记录」的用例,迟早会在并行执行时互相踩。
- 第三方依赖要拦掉。统计脚本、字体、客服组件走
page.route直接返回空响应,既快又不会因为外部服务抖动而红。 - flaky 要当 bug 修。
--repeat-each=10跑十遍,把偶发失败稳定复现出来,再去看 trace,而不是直接加retries。重试是止血,不是治疗。
CI 里通常用 pytest -n auto 按 worker 并行,一个 worker 一个浏览器。两个细节容易翻车:一是 trace 的产物路径要能区分 worker,否则互相覆盖;二是容器里缺系统库,浏览器起不来,官方提供的 playwright install --with-deps 在国内网络下经常卡住,提前烘进镜像更省事。
从 Selenium 迁过来,心智要换#
不是把 find_element 换成 locator 就完事,几个对应关系先理清:
- 没有
WebDriverWait和driver生命周期,fixture 直接给你page; - 断言用
expect(locator),不要自己取text_content()再assert,后者不会重试; - 多标签页用
context.expect_page()拿新页面,不再靠切 window handle; - iframe 用
frame_locator(),链式写法不用先switch_to; - 文件下载用
expect_download(),上传用set_input_files()。
迁移顺序建议从最痛的地方开始:先挑最 flaky 的那几条用例改写,用「失败率下降」说服团队,比一次性重写全部脚本靠谱。
截图对比也能做(to_have_screenshot),但坑在字体渲染:本地和 Linux CI 出的图经常不一致。要么统一在容器里生成基线,要么只截关键区域。组件测试模式仍标注为实验性,落地前以官方文档为准。
什么时候别用 Playwright#
| 需求 | 建议 |
|---|---|
| 只测 HTTP 接口 | 用 httpx / requests,快一个量级 |
| 需要 IE 或很老的浏览器 | 不支持,换别的方案 |
| 移动原生 App | 用 Appium 这类工具 |
| 大规模并发抓取公开网页 | 资源成本高,且要先看目标站点的条款 |
| 需要用户已登录的真实浏览器配置 | 能注入,但维护成本高,先想清楚是否必要 |
还有一条常被忽略:Playwright 跑得快,但每个浏览器上下文仍要占内存。CI 里 worker 数量要按机器内存调,开太多会 OOM,表现是随机超时,看起来像 flaky,其实是资源不够。
结论#
- 把「等待」交给
expect和自动等待,脚本里不该出现固定 sleep。 - 定位器优先用 role / label,其次 test id,最后才用 CSS。
- 每个用例独立上下文,数据自己造,第三方请求拦截掉。
- 失败先看 trace,再决定重试;
--repeat-each是复现 flaky 的好工具。 - 只测接口或要跑老浏览器时,别硬套这套工具。
完