Playwright web-first 断言的重试:toHaveText 到 Frame.expect
先从一个几乎每个人都写过的两行代码说起。判断「欢迎」文案出现了,有人写成这样:
// 👎
expect(await page.getByText('welcome').isVisible()).toBe(true);
有人写成这样:
// 👍
await expect(page.getByText('welcome')).toBeVisible();
这两段是 docs/src/best-practices-js.md 里原样给出的正反例。第一段偶发失败、第二段不会,原因不是「第二段更健壮」这种玄学,而是两条完全不同的执行路径。下面把这条路径按仓库里的文档和源码走一遍。
第一段为什么必然会偶发失败
关键不在 expect,在 isVisible()。docs/src/api/class-locator.md 里 Locator.isVisible 的 timeout 选项被标成了 deprecated,原文写的是:这个选项会被忽略,Locator.isVisible 不会等待元素变为可见,而是立即返回。同一份文件在 Locator.isVisible 的正文里还挂了一条 warning,直接建议改用 LocatorAssertions.toBeVisible。Locator.isHidden 的 timeout 也是同样的处理。
也就是说,第一段代码在 await 结束时已经把布尔值取到手了,交给 toBe(true) 的是一个死值。这时候页面还在渲染也好、请求还没回来也好,都跟这次断言没关系了 —— 它比的是一个过去时刻的快照。
第二段代码传给 expect 的不是布尔值,而是 locator 本身。差别就在这里:断言拿到的是一个「怎么找这个元素」的描述,于是它可以反复去找。
会重试和不会重试的,怎么一眼分开
docs/src/test-assertions-js.md 把断言分成了几组,其中「Auto-retrying assertions」那一组的定义是:会一直重试直到断言通过或者达到断言超时,并且这些断言是异步的,必须 await。文档在「Non-retrying assertions」一节里写得也很直白:网页大多是异步展示信息的,用不重试的断言容易写出 flaky 的测试,能用自动重试的就优先用。
判断方法不用背表,看两个特征就够:
- 接收的对象是
Locator、Page还是APIResponse—— 对应LocatorAssertions、PageAssertions、APIResponseAssertions三个类,docs/src/api/class-locatorassertions.md是前者的签名出处; - 前面是不是有
await。
toBeVisible()、toHaveText()、toHaveCount()、toBeAttached() 这类落在 LocatorAssertions 上的是会重试的一组;toBe、toEqual、toContain、toHaveLength 这些落在 GenericAssertions 上的是同步的、不重试的一组。第一段反例的问题正是把一个 web 场景硬塞进了后一组。
顺带一个容易踩的点:toHaveText 有个 useInnerText 选项(docs/src/api/class-locatorassertions.md),控制取文本时用 element.innerText 还是 element.textContent;文档的 Details 一节还写明,期望值传字符串时会对实际文本和期望字符串都做空白与换行的归一化,传正则时则按原样匹配。这不影响重不重试,但影响「重试到什么算通过」。
重试到底发生在哪一侧
这一步是最容易想当然的地方。很多人默认 Playwright 是在你的 Node 进程里 sleep 一下再查一次 —— 不是。
packages/playwright/src/matchers/toBeTruthy.ts 里可以看到,matcher 拿到 timeout 之后做的事情是把它交出去:
const timeout = options.timeout ?? this.timeout;
const { matches: pass, log, timedOut, received, errorMessage } = await query(!!this.isNot, timeout, options.signal);
这个 query 最终走到 packages/playwright-core/src/client/locator.ts 的 Locator._expect,再转给 Frame._expect,超时是作为参数一次性下发的。真正的循环在服务端 packages/playwright-core/src/server/frames.ts 的 Frame.expect 里,源码注释把它切成了三步:
- Step 1:
performActionPreChecks(progress),先按指定超时跑一遍 locator handlers 的检查点; - Step 2:做一次不带超时的 one-shot 检查。源码注释写明这是为了支持
expect(locator).toBeVisible({ timeout: 1 })这种写法 —— 元素本来就可见时它应该直接通过,而不是被极小的超时卡住; - Step 3:走
retryWithProgressAndBackoff,按递增的间隔重试,总时长受剩余时间约束。条件不匹配(或者not.形式下匹配了)时返回continuePolling继续等。
retryWithProgressAndBackoff 里的退避序列写作 const backoffScale = [20, 50, 100, 100, 500];,并且有一段逻辑:当 progress.timeout 很小时,会把序列尾部大于 timeout / 5 的值弹掉,注释说这是为了在小超时下也能产生有意义的重试次数。这些是仓库当前代码里的取值,随版本可能变动,不要写进你的测试假设里。
理解这一层有个实际好处:断言重试期间,轮询是在浏览器侧驱动的,你在 Node 里 console.log 是看不到中间态的;要看中间发生了什么,得看失败时打印的 Call log。docs/src/test-timeouts-js.md 里给的错误样例就是这个形状 —— 报错里会有一行 expect.toHaveText with timeout 5000ms,紧跟着 waiting for "locator('button')"。
超时是从哪一层取来的
packages/playwright/src/matchers/expect.ts 里这一行把优先级写死了:
const timeout = info.timeout ?? expectConfig().timeout ?? defaultExpectTimeout;
从右往左读:兜底是 defaultExpectTimeout,同一文件里赋值为 5000;中间一层是配置里的 expect.timeout,也就是 docs/src/test-api/class-testconfig.md 里 TestConfig.expect 的 timeout 字段;最外层是你在单次断言上传的 { timeout: ... },对应 docs/src/api/params.md 里 js-assertions-timeout 的说明:「Time to retry the assertion for in milliseconds. Defaults to timeout in TestConfig.expect.」以上默认值是仓库当前代码与文档里的取值,随版本可能变动。
还有一层不在这个 ?? 链上,但会真的截断你:packages/playwright/src/matchers/matchers.ts 的 deadlineForMatcher 会把 matcher 自己的 deadline 和测试整体的 deadline 取 Math.min,并且给测试 deadline 留了一小段余量;断言超时为 0 时,直接用测试 deadline。所以「断言超时」和「测试超时」是两套独立的值,但最终生效的是先到的那一个 —— 报错信息里 Timeout ...ms exceeded while waiting on the predicate 和 Test timeout of ...ms exceeded 两句话的区别就来自这里,看错哪句,调错参数。
语言绑定别串。上面这套 TestConfig.expect 是 JS/TS 侧 test runner 的配置。docs/src/api/params.md 里另有一段 csharp-java-python-assertions-timeout,langs 标的是 java、python、csharp,说明写的是「Defaults to 5000」——同样是默认值,但改法不一样:Java 侧在 docs/src/api/class-playwrightassertions.md 有 PlaywrightAssertions.setDefaultAssertionTimeout,标注 langs: java、since: v1.25,用法是 PlaywrightAssertions.setDefaultAssertionTimeout(30_000);。别拿 JS 的 config 写法去套其它绑定。
如果项目里只有少数几处天生就慢(比如等一份报表渲染完),改全局配置太粗、每处都传 { timeout } 又太碎,docs/src/test-assertions-js.md 里还有第三种办法:预置一个自己的 expect 实例。
const slowExpect = expect.configure({ timeout: 10000 });
await slowExpect(locator).toHaveText('Submit');
// Always do soft assertions.
const softExpect = expect.configure({ soft: true });
await softExpect(locator).toHaveText('Submit');
以上为仓库文档里的原样示例,其中的时长只是示例值。expect.configure 能一次性固定 timeout 与 soft 这类默认项,落在前面那条 ?? 链的 info.timeout 这一层,所以它仍然会被单次断言上传的 { timeout } 覆盖。另外提一句排查时好用的东西:expect 的第二个参数可以传自定义消息,文档写明这条消息在通过和失败两种情况下都会出现在 reporter 里,失败时它会顶在 Call log 上方 —— 一屏里有十几个断言时,这比回去数行号快。
另外,toBeVisible、toHaveText、toHaveCount 这些方法在 JS 侧还有个 signal 选项,docs/src/api/params.md 的 js-assertions-signal 标注 since: v1.62,语义是:signal 被 abort 时断言像超时一样失败,正在重试的会停止重试,事先就已 abort 的则一次都不重试。
expect.poll 与 expect.toPass 补在什么位置
自动重试只覆盖那批 web-first 断言。要判断的东西不是元素状态时(比如接口返回值、数据库里的一行),docs/src/test-assertions-js.md 给的出口是这两个。
expect.poll 把一个同步 expect 变成轮询版,第一个参数必须是函数:
await expect.poll(async () => {
const response = await page.request.get('https://api.example.com');
return response.status();
}, {
// Custom expect message for reporting, optional.
message: 'make sure API eventually succeeds',
// Poll for 10 seconds; defaults to 5 seconds. Pass 0 to disable timeout.
timeout: 10000,
}).toBe(200);
以上为仓库文档里的原样示例,其中的具体取值只是示例值。expect.ts 里对应的实现有两处显式抛错值得记住:第一个参数不是函数时抛 expect.poll() accepts only function as a first argument;用在 resolves/rejects 或者任何异步 matcher 上时抛 expect.poll() does not support "..." matcher。也就是说,不要试图把 toBeVisible 这类断言塞进 expect.poll 里 —— 它们本来就自带重试,语义会打架,代码层面也直接拒绝。它的轮询间隔来自 poll.intervals ?? [100, 250, 500, 1000],超时链是 poll.timeout ?? info.timeout ?? expectConfig().timeout ?? defaultExpectTimeout,比普通断言多了最前面一层。
expect.toPass 则是重试一整块代码,块里可以放多个普通 expect:
await expect(async () => {
const response = await page.request.get('https://api.example.com');
expect(response.status()).toBe(200);
}).toPass();
这里有一处反直觉、必须记住的差异,文档原话是:toPass 默认超时为 0,并且不遵循自定义的 expect timeout。对应到 matchers.ts 里就是 options.timeout ?? expectConfig().toPass?.timeout ?? 0,expect.timeout 根本不在这条链上;要全局改,得配 TestConfig.expect 下的 toPass 子对象(它有自己的 timeout 与 intervals 两个字段)。超时为 0 也不等于会一直跑下去 —— 前面说的 deadlineForMatcher 会让它落到测试整体 deadline 上。
两者都能和 soft 组合,文档里给了 expect.soft.poll(...) 与 expect.configure({ soft: true }) 再 .poll(...) 两种写法。注意 soft 断言只在 Playwright 的 test runner 下有效,这句是 docs/src/test-assertions-js.md 自己写的。
还有一条边界:expect.poll 与 expect.toPass 这两节只出现在 docs/src/test-assertions-js.md;语言无关那份 docs/src/test-assertions-csharp-java-python.md 里,我们没有找到对应的说明。写其它绑定时别照搬。
落到写法上
把上面几段压成三句可执行的:await 直接放在 expect 前面而不是里面,传进去的是 locator 不是取好的值;改等待时长先分清你要改的是断言超时还是测试超时,看报错里那句话是 predicate 还是 test timeout;元素状态之外的东西才动 expect.poll / expect.toPass,并且记得 toPass 不吃 expect timeout。
这一层是纯 API 语义,仓库文档里没有针对 Windows 与 Linux/macOS 分别给出的说明,因此不存在平台差异;Windows 上需要注意的是别的环节(浏览器安装、CI 镜像),跟断言重试无关。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。