Playwright 前端自动化实战:从 Selenium 迁移的完整思路

2023 年把 Web 端自动化从 Selenium 整体迁到 Playwright,迁移本身 2 周,但迁移带来的稳定性提升才是真正目的:等待问题消失、选择器更聪明、并发免费。这篇记录迁移的完整思路和踩过的坑。

一、为什么迁:Selenium 的三个结构性短板

短板 具体表现 Playwright 的解法
等待全靠人肉 显式等待要自己写 WebDriverWait + 条件,90% 的 flaky 来自"等错了地方" 自动等待:所有 action 前自动等元素可交互(actionability checks),显式等待变成少数场景
单线程单浏览器 并发要 WebDriverPool + 线程 + 浏览器进程管理,复杂且脆 原生并发:async API + 浏览器上下文隔离,context 比 driver 轻得多
网络是黑盒 改请求/拦截响应要靠第三方库硬 hack route 拦截是一等公民:Mock 响应、改请求头、断言请求,全在 API 里

迁移收益最大的一条:Selenium 时代 flaky 用例占失败量的 35%,迁完降到 6%——大头就是等待问题被自动等待吃掉了。

二、定位策略:从"我能写什么"到"最稳定的是什么"

Playwright 的选择器按稳定性重排了优先级:

1
2
3
4
5
6
7
# 优先级从高到低(团队约定,CI lint 强制)
page.get_by_role("button", name="提交") # 1. ARIA 角色 + 可访问名(最稳,贴用户视角)
page.get_by_label("手机号") # 2. 表单标签
page.get_by_placeholder("请输入订单号") # 3. placeholder(文案类,多语言有风险)
page.get_by_test_id("pay-btn") # 4. data-testid(开发约定的稳定锚点)
page.locator(".order-list tr") # 5. CSS(结构性,UI 改版会挂)
page.locator("//div[@x=1]") # 6. XPath(最后手段,CI lint 告警)

get_by_role 是思维转变的关键:它强迫用例从"用户视角"描述元素(“提交按钮"而不是"第三个 div 里的 span”),和可访问性测试天然对齐——按钮没写 accessible name 的用例定位失败,顺带逼出了产品的 a11y 问题

三、网络拦截:Mock 能力让"环境依赖"消失

1
2
3
4
5
6
7
8
9
10
11
12
13
# 支付回调 Mock:不用真的调第三方支付,回调想怎么来怎么来
async def test_pay_timeout_retry(page, context):
# 支付网关超时(真实环境造不出来)
await context.route("**/gateway/notify", lambda route: route.fulfill(
status=504, body="Gateway Timeout"))
await submit_order(page)
# 断言:用户侧看到"支付处理中",重试按钮出现
await expect(page.get_by_role("button", name="重新支付")).to_be_visible()

# 接口数据 Mock:UI 用例不再依赖后端数据状态
await context.route("**/api/orders", lambda route: route.fulfill(
body=json.dumps({"items": make_orders(50)}), # 50 单列表,验证分页
content_type="application/json"))

原则:UI 用例只验证"给定 X 响应,UI 表现 Y"。数据准备从"调接口造数据"(慢、脏、依赖环境)变成"route 直接给"(快、干净、零依赖)。UI 回归从 40 分钟压到 12 分钟,稳定性反而更高——因为不再和真实数据状态搏斗

四、并发与报告:免费的规模化

1
2
3
# pytest-playwright 天然支持并发:每个 worker 独立浏览器实例
# 命令级并发,不需要 WebDriverPool
# pytest -n 8 --dist loadscope tests/web/
1
2
3
4
5
6
7
8
9
# 失败现场:trace 是迁移后最惊喜的功能
async def pytest_runtest_makereport(item, call):
if call.when == "call" and call.excinfo is not None:
# trace.zip:逐步的 DOM 快照 + 网络 + 控制台,时间轴回放
await page.context.tracing.stop(path=f"traces/{item.name}.zip")

# 上传 trace 到报告,失败用例"点开时间轴"回放:
# 第 3 步点按钮 → 第 4 步 DOM 里按钮还在但 disabled → 定位到 JS 状态问题
# 过去这类问题要"现场复现 + 打断点",现在 30 秒看完

trace 回放把"为什么挂"从口口相传变成可视化证据——研发不再说"我这边复现不了",因为 DOM 快照把当时的状态钉死了。

五、迁移踩坑记录

现象 解法
自动等待"等过头" 元素确实不可交互,用例卡满 timeout(默认 30s) 区分"慢"和"不会":expect 断言可见性用短 timeout 快速失败
strict mode 违反 选择器命中多个元素直接报错 这是特性不是 bug——多命中说明选择器太宽,修选择器
iframe 里操作 元素定位不到 frame = page.frame_locator("iframe#pay"); frame.get_by_role(...),Playwright 自动处理 frame 等待
文件上传 send_keys 对 input[type=file] 无效 page.set_input_files("input[type=file]", "a.pdf"),一步到位
影子 DOM CSS 选择器穿透不了 Playwright 的 CSS 引擎原生支持 >>> 穿透

六、迁移策略:不要大爆炸

我们的迁移顺序(2 周完成 600 条用例):

  1. 新用例直接用 Playwright 写(第 1 周,并行跑两套);
  2. flaky 最重的 20% 先迁(等待问题最严重的那批,收益最大);
  3. 按模块整体切(一个模块迁完就切,不搞半吊子);
  4. Selenium 套件冻结(不再接新用例,只修必修的),3 个月后下线。

七、小结

Playwright 迁移的本质是三个转变:等待从人肉到自动(flaky 大头消失)、定位从开发者视角到用户视角(get_by_role + a11y 对齐)、网络从黑盒到可编程(route 拦截让 UI 用例环境独立)。工具选型看 100 分能力,迁移成功看的是策略——新用例先行、flaky 先迁、按模块切换,别搞大爆炸。