Playwright 等待策略实战:告别 sleep 与 flaky 测试
为什么你的自动化脚本总是"偶尔失败"
写自动化脚本的人大概都经历过这种场景:本地跑十次都过,CI 上跑三次挂一次。
这类问题在测试圈有个专门的名字——flaky(不稳定)。它的根源往往不是脚本写错了,而是等待策略不对。
本文梳理 Playwright 的等待机制,给出从"能跑"到"稳跑"的实践路径。
一、最常见的错误:用 sleep 硬等
新手写法通常是这样的:
await page.click('#submit');
await page.waitForTimeout(3000); // 硬等 3 秒
await page.click('#next');
这种写法有三个致命问题:
- 等太久浪费时间——实际 200ms 就绪,却要等 3 秒;一百个用例就是五分钟的浪费
- 等不够就挂——网络抖动时 3 秒不够,直接失败
- 掩盖真实问题——把"元素还没出现"和"元素永远不会出现"混为一谈
waitForTimeout 在官方文档里被明确标注为不推荐。它唯一的合理用途是调试时暂停观察。
二、Playwright 的自动等待(Auto-waiting)
Playwright 的核心优势是内置自动等待。绝大多数操作(click / fill / check)在执行前会自动完成一系列可操作性检查:
| 检查项 | 说明 |
|---|---|
| Attached | 元素已挂载到 DOM |
| Visible | 元素可见(非 display:none、非零尺寸) |
| Stable | 元素位置稳定(动画结束) |
| Enabled | 元素未被禁用 |
| Receives Events | 元素没有被其他元素遮挡 |
这意味着大部分场景下你不需要写任何等待:
// 不需要 waitForSelector,Playwright 会自己等
await page.click('#submit');
await page.fill('#title', 'hello');
只有当默认超时(30 秒)内条件始终不满足时,才会抛出超时错误。
三、需要显式等待的三种场景
场景 1:等待"状态"而不是"元素"
元素可能一开始就存在,但状态会变化。这时要等的是状态:
// 等待按钮变为可用
await page.locator('#submit').waitFor({ state: 'visible' });
// 等待元素消失(比如 loading 遮罩)
await page.locator('.loading-mask').waitFor({ state: 'hidden' });
// 等待元素从 DOM 中移除
await page.locator('.toast').waitFor({ state: 'detached' });
四种状态:attached / detached / visible / hidden。
场景 2:等待网络请求
页面数据来自异步接口,元素要等接口返回后才渲染:
// 等待特定接口返回
const respPromise = page.waitForResponse(
(r) => r.url().includes('/api/list') && r.status() === 200
);
await page.click('#refresh');
await respPromise;
// 等待网络空闲(谨慎使用,长轮询页面会一直不空闲)
await page.waitForLoadState('networkidle');
waitForResponse 比 networkidle 更精准,推荐优先使用。
场景 3:等待 URL 或弹窗
// 等待跳转
await Promise.all([
page.waitForURL('**/dashboard'),
page.click('#login'),
]);
// 等待新标签页
const [newPage] = await Promise.all([
ctx.waitForEvent('page'),
page.click('a[target=_blank]'),
]);
注意:page.click() 可能触发跳转,所以要用 Promise.all 同时发起等待和点击,否则可能错过事件。
四、断言也要用"可重试"的版本
这是最容易被忽视的一点。看这段代码:
// 危险:立即取值,没有重试
const text = await page.textContent('.status');
expect(text).toBe('已完成');
如果页面还没更新,这个断言会立刻失败,不会重试。
正确做法是用 Playwright 自带的重试断言:
const { expect } = require('@playwright/test');
// 自动重试,直到超时
await expect(page.locator('.status')).toHaveText('已完成');
await expect(page.locator('.item')).toHaveCount(5);
await expect(page.locator('#submit')).toBeEnabled();
expect 配合 locator 会自动轮询重试(默认 5 秒),这是消除 flaky 的关键手段。
五、超时配置的分层策略
超时不应该是一个全局常量,而应该分层设置:
// 全局默认
const ctx = await browser.newContext();
ctx.setDefaultTimeout(30000); // 单次操作
ctx.setDefaultNavigationTimeout(60000); // 页面导航
// 局部覆盖:慢接口单独放宽
await page.locator('.slow-report').click({ timeout: 120000 });
经验值参考:
| 场景 | 建议超时 |
|---|---|
| 普通元素交互 | 10–30 秒 |
| 页面导航 | 30–60 秒 |
| 复杂报表 / 大数据量渲染 | 60–120 秒 |
| 异步任务轮询 | 用显式循环,别靠超时 |
六、轮询式等待:处理"长时间异步任务"
有些操作(比如生成报表、跑一次构建)可能耗时几分钟,超时设置多大都不合适。这时应该显式轮询:
async function waitForCompletion(page, maxMinutes = 10) {
const deadline = Date.now() + maxMinutes * 60 * 1000;
while (Date.now() < deadline) {
const status = await page.locator('.task-status').textContent();
if (status.includes('完成')) return true;
if (status.includes('失败')) throw new Error('任务失败: ' + status);
await page.waitForTimeout(5000); // 轮询间隔
}
throw new Error('等待超时');
}
关键点:
- 有明确的上限,不会无限等待
- 识别失败状态,失败就快速退出而不是傻等
- 轮询间隔合理,太密会打爆服务端
七、稳定性检查清单
把脚本从"能跑"提升到"稳跑",逐条对照:
- 不用
waitForTimeout—— 除了调试 - 断言用
expect(locator)—— 而不是先取值再比较 - 等状态而不是等元素 ——
visible/hidden/detached - 等接口而不是等时间 ——
waitForResponse优先于networkidle - 跳转用
Promise.all—— 避免事件竞争 - 超时分场景设置 —— 慢操作单独放宽
- 失败时留现场 —— 截图 + HTML + 日志(见下)
八、失败现场保留
再稳的脚本也会失败,关键是失败时能快速定位:
try {
await doSomething(page);
} catch (e) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
await page.screenshot({ path: `out/fail_${ts}.png`, fullPage: true });
fs.writeFileSync(`out/fail_${ts}.html`, await page.content());
console.error('失败 URL:', page.url());
throw e;
}
截图看视觉状态、HTML 看 DOM 结构、URL 看是否跳错页面——三件套能覆盖绝大多数排查场景。
小结
| 问题 | 错误做法 | 正确做法 |
|---|---|---|
| 等元素出现 | waitForTimeout(3000) |
依赖自动等待 |
| 等元素消失 | 循环查 DOM | waitFor({state:'hidden'}) |
| 等接口返回 | networkidle |
waitForResponse |
| 断言 | expect(await textContent()) |
expect(locator).toHaveText() |
| 长任务 | 设置超大超时 | 显式轮询 + 上限 |
| 失败排查 | 只看报错 | 截图 + HTML + URL |
核心心法:等待"条件成立",而不是等待"时间流逝"。
本文为实践总结,示例基于 Playwright 的 Locator API。
- 点赞
- 收藏
- 关注作者
评论(0)