[Playwright][06]自动等待与断言:如何减少 Flaky Tests?
写 UI 自动化测试时,有一种情况很烦人:
同一条测试,运行一次成功,再运行一次却失败了。
这种结果不稳定的测试,通常被称为 Flaky Test。
明明元素就在页面上,脚本却报错说找不到;或者按钮已经点击成功了,后面的断言却失败了。
它不一定意味着业务功能存在 Bug,也可能是测试脚本没有正确处理页面加载、异步更新或执行时序。
这时候最容易想到的办法就是加一句:
awaitpage.waitForTimeout(3000);让脚本先等 3 秒。
但问题来了:页面如果只需要 500ms,剩下的时间就白等了;如果 CI 环境比较慢,3 秒又不够,测试还是会失败。
与其猜页面需要多少秒,不如让测试去判断:我想操作的元素准备好了吗?我期待的结果出现了吗?
这也是 Playwright 的 Auto-waiting 和 Auto-retrying Assertions 想解决的问题。
一、Auto-waiting:按钮还没出现,Playwright 会怎么办?
先说实验场景
假设我们正在测试一个管理后台。
用户打开页面后,系统需要加载一些数据。为了避免用户在数据准备好之前提交操作,页面上的 Submit 按钮会先隐藏,等数据加载完成后才显示。
我们把这个过程简化一下:
- 页面打开时,Submit 按钮是隐藏的。
- 2 秒后,模拟数据加载完成,按钮显示出来。
- 自动化脚本需要点击这个按钮。
这里想验证的是:按钮还没显示出来时,Playwright 会直接报错,还是会等它出现?
为了不依赖真实网站,我们用page.setContent()创建一个临时网页。
完整代码
import{test,expect}from'@playwright/test';test('Auto Waiting Demo',async({page})=>{awaitpage.setContent(`<button id="submit" style="display:none"> Submit </button> <script> setTimeout(() => { document.querySelector('#submit').style.display = 'block'; }, 2000); </script>`);awaitpage.locator('#submit').click();awaitexpect(page.locator('#submit')).toBeVisible();});代码在做什么?
先看这一段:
<buttonid="submit"style="display:none">Submit</button>我们创建了一个 Submit 按钮,但通过display:none把它隐藏起来。
接着:
setTimeout(()=>{document.querySelector('#submit').style.display='block';},2000);这是网页自己的 JavaScript,意思是:2 秒后,把 Submit 按钮显示出来。
注意,这里的setTimeout()不是让 Playwright 等待,而是故意模拟一个加载较慢的页面。
最后才是 Playwright 的操作:
awaitpage.locator('#submit').click();虽然执行到这里时按钮可能仍然隐藏,但click()不会马上放弃。
Playwright 会等待按钮满足点击条件,例如可见、稳定、已启用并且能够接收点击事件,然后再执行点击。
这就是Auto-waiting(自动等待)。
Auto Waiting Demo 的运行结果,执行click花了2.4s
这里我觉得比较重要的一点是:
Playwright 等待的不是固定的 2 秒,而是按钮什么时候真正可以点击。
如果按钮提前出现,就可以提前点击;如果按钮一直不满足条件,操作最终也会超时失败。
不过,按钮能点击,并不代表点击之后的业务操作已经完成。
这就引出了第二个实验。
二、Auto-retrying Assertions:点击成功,结果却还没出来
先说实验场景
还是管理后台,这次换成一个更常见的操作:保存数据。
页面上有一个 Save 按钮。用户点击后,系统需要向后台提交数据,保存成功才会显示:
Saved successfully
我们模拟一下这个过程:
- Save 按钮一开始就能点击。
- 用户点击 Save。
- 系统花 2 秒处理保存操作。
- 页面显示保存成功的提示。
这里要测试的不是按钮能否点击,而是:点击之后,页面有没有出现预期的成功消息?
完整代码
import{test,expect}from'@playwright/test';test('Auto Retry Assertion Demo',async({page})=>{awaitpage.setContent(`<button id="save">Save</button> <p id="message"></p> <script> document.querySelector('#save').addEventListener('click', () => { setTimeout(() => { document.querySelector('#message').textContent = 'Saved successfully'; }, 2000); }); </script>`);awaitpage.locator('#save').click();awaitexpect(page.locator('#message')).toHaveText('Saved successfully');});代码在做什么?
先创建两个元素:
<buttonid="save">Save</button><pid="message"></p>一个是保存按钮,另一个是用来显示保存结果的文本区域。
然后给按钮绑定点击事件:
document.querySelector('#save').addEventListener('click',()=>{setTimeout(()=>{document.querySelector('#message').textContent='Saved successfully';},2000);});意思是:点击 Save 后,过 2 秒才把成功消息写到页面上。
这里同样没有真正调用后台 API,只是用延迟来模拟异步保存。
接下来,Playwright 点击按钮:
awaitpage.locator('#save').click();因为 Save 按钮从一开始就可以操作,所以这一步通常很快就能完成。
但是,此时成功消息还没出现。
如果我们立即读取文字:
constmessage=awaitpage.locator('#message').textContent();expect(message).toBe('Saved successfully');测试就可能失败,因为textContent()读取的是当时的内容,普通的expect(message).toBe()不会重新读取页面。
更合适的方式是:
awaitexpect(page.locator('#message')).toHaveText('Saved successfully');toHaveText()属于 Playwright 的自动重试断言。
当页面文字还不符合预期时,它会在断言超时范围内继续检查,直到文字匹配,或者等待超时。
Auto Retry Assertion Demo 的运行结果
所以,click()成功只是说明点击操作完成了;toHaveText()才是在确认页面出现了预期结果。
三、这两种等待到底有什么区别?
两个实验看起来很相似,都是页面过了 2 秒才发生变化。
但 Playwright 等待的东西完全不同。
| Demo 1:Submit | Demo 2:Save | |
|---|---|---|
| 初始状态 | 按钮隐藏 | 按钮可点击,结果为空 |
| 页面延迟发生什么 | 按钮显示 | 成功消息出现 |
| 主要等待方法 | locator.click() | expect(locator).toHaveText() |
| 等待的条件 | 元素可以操作 | 页面状态符合预期 |
| 对应机制 | Auto-waiting | Auto-retrying Assertions |
简单理解:
Auto-waiting 解决的是“什么时候可以操作”,Auto-retrying Assertions 解决的是“操作后有没有达到预期”。
这也是为什么实际写 E2E 测试时,通常需要把操作和验证配合起来。
例如:
awaitpage.getByRole('button',{name:'Save'}).click();awaitexpect(page.getByText('Saved successfully')).toBeVisible();它表达的意思很明确:先点击保存,再确认保存成功。
四、回到实际项目,为什么还是会出现 Flaky Tests?
Playwright 有自动等待,是不是就不会再出现 Flaky Test 了?
当然不是。
比如,测试依赖其他用例创建的数据,但数据还没有准备好;或者页面上有多个相似按钮,Locator 匹配到了错误的元素。
这些问题并不是简单等几秒就能解决的。
还有一种容易忽略的情况:测试只验证了操作发生,却没有验证业务结果。
以之前的 Bing 搜索为例:
awaitpage.locator('#sb_form_q').fill('Playwright');awaitpage.locator('#search_icon').click();awaitexpect(page).toHaveURL(/bing\.com\/search/);这段代码能够验证搜索后的 URL 是否符合预期,但并不能证明搜索结果内容已经加载完成。
如果测试目标是验证搜索结果,就应该进一步检查结果区域或具体内容,而不是只看 URL。
因此,我现在更倾向于先想清楚测试目标,再决定应该等待什么状态,而不是一遇到失败就增加waitForTimeout()。
五、总结
这次用两个简单的动态页面,分别模拟了按钮延迟出现和保存结果延迟返回。
我觉得最值得记住的不是某个 API,而是两个问题:
操作之前:元素真的可以操作了吗?
操作之后:页面真的达到预期状态了吗?
Playwright 的 Auto-waiting 和自动重试断言,正好分别帮助我们处理这两个问题。
相比到处添加固定等待,明确地等待元素状态和业务结果,通常更可靠,也更方便排查失败原因。
当然,Flaky Test 还可能与测试数据、执行顺序、网络和环境有关。自动等待只能解决其中一部分问题。