Playwright 里五种"看起来能跑、实际会偶发失败"的写法
自动化脚本最难受的状态不是报错,是昨天跑通了,今天跑挂了,明天又好了。
这种偶发失败通常不是环境问题,而是写法本身留了空档。下面五种是我在实际项目里反复见到的,每一种都能通过改一行代码消除。
一、用文本定位按钮
await page.getByText('提交').click();
页面只有一个"提交"时没问题。等产品加了个"提交反馈",或者弹窗里出现"取消 / 提交"两个按钮,这行代码就会开始随机点到别的元素上。
更隐蔽的情况是:点到了正确的元素,但那是个隐藏的副本(列表项和详情页各渲染一份)。点击成功、无报错、状态不变。
改成把语义限定住:
// 限定到具体容器
await page.locator('.editor-footer').getByRole('button', { name: '提交' }).click();
// 或者用测试属性,最稳
await page.getByTestId('submit-btn').click();
判断标准很简单:这行定位在一百次运行里,能不能保证永远命中同一个元素。只靠文字,答案是不能。
二、点完立刻断言
await page.getByText('保存').click();
const text = await page.locator('.toast').innerText();
expect(text).toContain('成功');
click() 返回只代表事件发出去了,不代表服务端回了、界面更新了。中间那段空白就是竞态窗口。
正确做法是断言"可观测的状态变化",让 Playwright 自己重试:
await expect(page.locator('.toast')).toContainText('成功', { timeout: 10000 });
expect 带自动轮询,innerText() 不带。用后者做断言,等于自己承担了所有时序风险。
同理,点击后要等页面跳转时,别用 waitForTimeout:
// 不稳
await page.getByText('下一步').click();
await page.waitForTimeout(2000);
// 稳
await Promise.all([
page.waitForURL(/\/step-2/),
page.getByText('下一步').click(),
]);
Promise.all 的写法能避免"跳转太快、等待发生在跳转之后"这种漏等。
三、用 offsetParent 判断可见性
const visible = await page.evaluate(() => {
const el = document.querySelector('.modal');
return !!el && el.offsetParent !== null;
});
这个判断在三种情况下会给出错误答案:
- 元素用了
position: fixed,offsetParent本身就是null - 元素在 iframe 里,主文档的
querySelector根本查不到 - 元素有父级设了
display: contents
改用 Playwright 自带的可见性判定,它算的是实际渲染结果:
await expect(page.locator('.modal')).toBeVisible();
// 或者
const visible = await page.locator('.modal').isVisible();
顺带说 iframe 那条:如果你的 querySelectorAll 返回 0,页面却明明有那个元素,先去看 page.frames()。这不是选择器写错,是找错了文档。
四、无条件 waitForTimeout
固定等待的问题不是"慢",是它掩盖了真正该等的条件。
await page.goto(url);
await page.waitForTimeout(5000);
await page.getByText('开始').click();
这段在本地跑一百次都成功。换台机器、换个网络、赶上服务端慢一点,5 秒不够,脚本就挂在一个和业务无关的地方。
该等的是"页面上出现了某个标志物":
await page.goto(url);
await page.locator('.dashboard-ready').waitFor({ state: 'visible' });
await page.getByText('开始').click();
不过也有该用 waitForTimeout 的时候,别一刀切。两种情况我会保留它:
- 第三方组件有冷却期,没有任何 DOM 信号可等(比如按钮点击后 60 秒内禁用)
- 页面在等一件无法观测的外部事件(人工扫码、短信验证码)
这两种情况下,固定等待加一次校验,比硬找一个不存在的信号更实际。
五、在错误的层级找元素
await page.locator('.cm-content').click();
写这行的时候,很多人默认它在主文档里。但富文本编辑器、内嵌预览、部分后台系统的内容区,大量使用 iframe 或 Shadow DOM。元素存在,只是不在 page 这一层。
先确认层级,再决定定位方式:
// 列一下所有 frame
console.log(page.frames().map(f => f.url()));
// iframe 内的元素用 frameLocator
await page.frameLocator('iframe#editor').locator('.cm-content').click();
Shadow DOM 更麻烦一点,需要逐层穿透:
await page.locator('my-component').locator('inner-element').click();
Playwright 的 locator 会自动穿透开放的 shadow root,但 evaluate 里的 querySelector 不会——这也是"用 evaluate 查不到、用 locator 能点到"的常见原因。
六、小结
五种写法,对应五个判断标准:
| 写法 | 风险 | 替代 |
|---|---|---|
| 纯文本定位 | 命中多个或隐藏副本 | 限定容器 / testid |
| 点完立刻断言 | 竞态,偶发失败 | expect 自动轮询 |
| offsetParent 判可见 | fixed / iframe / display:contents 全错 | toBeVisible() |
| 无条件固定等待 | 环境一变就挂 | 等标志元素 |
| 默认在主文档找 | iframe / Shadow DOM 找不到 | frameLocator 穿透 |
一句话总结:让脚本等待"状态",不要等待"时间";让定位绑定"结构",不要绑定"文字"。
这两条做到,偶发失败会少一大半。剩下的那种"偶尔还是挂",基本都能靠失败时自动截图定位——毕竟能复现的问题都好修,怕的是不知道挂在哪一步。
文中示例基于 Playwright 常见 API,写法在 Chromium 内核下验证。
- 点赞
- 收藏
- 关注作者
评论(0)