Playwright 的三类断言:会重试的、不重试的、软断言
写端到端用例的人多半都遇到过这个场面:本地跑得好好的一条断言,进了 CI 就时不时红一次,重跑又绿了。翻日志会发现失败的往往不是点击,而是点击之后那一行 expect。这时候真正该问的不是”要不要加个等待”,而是——你刚才那行 expect 到底会不会重试。
Playwright 仓库把断言分成了几节来讲,docs/src/test-assertions-js.md 里依次是 Auto-retrying assertions、Non-retrying assertions、Asymmetric matchers、Negating matchers、Soft assertions。后两类不是独立的断言种类:Asymmetric matchers 那节的原话是这些表达式可以嵌套进别的断言里做宽松匹配,Negating matchers 那节讲的是在 matcher 前面加 .not 取反。真正决定运行时行为的是三条线:重试与否、失败后中不中断、以及你用的是哪个语言绑定。
第一类:会重试的断言,判定方式是看断言对象
文档里 Auto-retrying assertions 一节写明:这些断言会一直重试,直到断言通过或者到达断言超时;并且提醒它们是异步的,在 JavaScript 里必须 await。
await expect(page.getByTestId('status')).toHaveText('Submitted');
这段来自 docs/src/test-assertions-js.md。文档对它的描述是:Playwright 会反复重新获取那个元素并检查,直到条件满足或超时。
判定方式其实很机械——看你把什么东西传给了 expect。那张 Auto-retrying 表里的条目,接受的都是 locator、page 或者 response:expect(locator).toBeVisible()、expect(page).toHaveURL()、expect(response).toBeOK()。对应的类文档分别是 docs/src/api/class-locatorassertions.md、docs/src/api/class-pageassertions.md、docs/src/api/class-apiresponseassertions.md。换句话说,只要断言的主语是页面上的东西,它就是会重试的那一类。
超时来自哪里?docs/src/test-timeouts-js.md 的表格里 Expect timeout 一行给的是 5_000 ms,docs/src/test-api/class-testconfig.md 里 TestConfig.expect 的 timeout 字段描述也是”defaults to 5000ms”。这是仓库当前文档里的默认值,随版本可能变动。改法有两处,配置里统一改:
export default defineConfig({
expect: {
timeout: 10000,
},
});
或者单条断言上改:await expect(locator).toHaveText('hello', { timeout: 10_000 });。这里的 10000 只是仓库文档里的示例值,不是推荐值。
第二类:不重试的断言,它只是普通的值比较
Non-retrying assertions 那张表里是 toBe、toEqual、toContain、toHaveLength、toThrow 这些,对应 docs/src/api/class-genericassertions.md。文档对这一节的定性很直接:它们能测任何条件,但不会自动重试;而网页大多是异步呈现信息的,用不重试的断言容易写出 flaky 的用例。文档给出的建议是优先用会重试的那类,复杂条件确实需要重试时改用 expect.poll 或 expect.toPass。
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.toPass 则是把一整块代码反复跑到不抛错为止。这两个都能指定 intervals,文档注释里写的默认探测间隔是 [100, 250, 500, 1000]——同样是当前文档里的默认值,随版本可能变动。
这里有一个反直觉的点,是仓库文档自己特意点出来的:toPass 默认 timeout 为 0,并且不遵循自定义的 expect timeout。也就是说你在配置里把 expect.timeout 调大,对 toPass 不起作用;TestConfig.expect 下另有一个独立的 toPass 对象,带自己的 timeout 与 intervals。如果你曾经把 toPass 当成”跟其它断言一样受全局超时管”的东西,那这就是它偶尔行为不符合预期的原因。
第三类:软断言,关键在失败怎么累积
软断言的定义是:断言失败不终止用例执行,但会把用例标记为失败。写法是 expect.soft(...)。
只写到这里是不够用的,重点在后半段——失败去哪儿了。docs/src/test-assertions-js.md 给的是这一段:
// Make a few checks that will not stop the test when failed...
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await expect.soft(page.getByTestId('eta')).toHaveText('1 day');
// Avoid running further if there were soft assertion failures.
expect(test.info().errors).toHaveLength(0);
TestInfo.errors 在 docs/src/test-api/class-testinfo.md 里的类型是 Array<TestInfoError>,描述是”用例执行期间抛出的错误”;同一文件里 TestInfo.error 的描述写明它等于 errors 的第一个元素。所以软断言的失败并没有消失,而是攒在这个数组里,你可以在任意时刻读它,判断”要不要继续往下走”。docs/src/best-practices-js.md 的 Use Soft assertions 一节对这个机制的说法是:软断言不会立刻终止执行,而是在用例结束后汇总并展示一份失败断言的清单。
还有两个组合用法值得记住:expect.configure({ soft: true }) 可以造一个默认走软断言的 expect 实例;expect.soft.poll(...) 可以让轮询里的断言也走软失败。
一句限定要跟着写:JS 侧文档明确说明软断言只在 Playwright test runner 下工作。你如果是拿 Playwright 当库、跑在别的测试框架里,这条不成立。
跨语言:同一件事,四种绑定不一样
这一节容易踩坑,因为社区博客大多只写 JavaScript。仓库里 docs/src/test-assertions-js.md 与 docs/src/test-assertions-csharp-java-python.md 是两份分开的文档,内容并不对齐。
不重试的那一类,在非 JS 绑定里没有对应物。 docs/src/api/class-genericassertions.md 头部标的是 * langs: js,PlaywrightAssertions.expectGeneric 也标 langs: js。而 test-assertions-csharp-java-python.md 只有一张 List of assertions 表,里面全是 LocatorAssertions、PageAssertions、APIResponseAssertions 的条目——也就是全是会重试的那一类。至于 Python、Java、C# 里普通值比较该怎么写,这一点我们在仓库文档里没有找到统一的说明,不比。
取反的写法分两派。 LocatorAssertions.not 这个属性标的是 langs: java, js, csharp:
assertThat(locator).not().containsText("error");
Python 走的是另一条路——class-locatorassertions.md 里给 Python 单独定义了 NotToBeAttached、NotToBeChecked 这一整套方法,落到 Python 代码里就是 not_to_be_visible() 这种形式,docs/src/touch-events.md 里的 Python 示例用的正是它。所以从 JS 的 .not.toBeVisible() 平移到 Python 时不能照抄链式取反。
软断言目前只有 JS 与 Python 两份说明。 Python 那份是同步写法,并且带了一条硬性前提:
expect.soft(page.get_by_test_id("status")).to_have_text("Success")
文档写明它只在 pytest-playwright(或 pytest-playwright-asyncio)插件 0.8.0 或更新的版本上工作。Java 与 C# 的软断言,我们在这两份断言文档里没有找到对应说明,不比。同样地,expect.poll 与 expect.toPass 只出现在 JS 那份文档里,另一份里没有对应章节。
全局超时的入口四种绑定各不相同。 JS 在配置文件的 expect.timeout;Python 是 conftest.py 里 expect.set_options(timeout=10_000);C# 在测试类初始化里调 SetDefaultExpectTimeout(10_000);Java 则是 PlaywrightAssertions.setDefaultAssertionTimeout(30_000),这个方法在 class-playwrightassertions.md 里标 langs: java。上面这些数值都是各自文档里的示例值。
以上代码片段均取自仓库文档,未做改写;组合到你自己的工程里时,以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。断言这一层我们在仓库文档里没有看到 Windows 与 Linux/macOS 的行为差异说明,因此不分平台展开;差异更可能出现在浏览器安装与 CI 环境那一层,不在本文范围内。
从处境倒推:该用哪一类
- 你在断言页面上的某个东西(文本、可见性、URL、响应状态)→ 用会重试的那一类,别自己写等待。它本来就会重新获取元素反复检查。
- 你在断言一个已经拿到手的普通值(数组长度、对象结构、抛没抛错)→ 只能用不重试的那类,但要意识到它是一次性判定;如果这个值本身是异步才稳定下来的,改用
expect.poll(断言返回值)或expect.toPass(重试整块代码),并且记得给toPass单独配超时。 - 一条用例里要检查多个互相独立的点,你不想第一个失败就看不到后面→ 用
expect.soft,并在关键分叉前用test.info().errors判一次要不要继续。前提是你跑在 Playwright test runner 上;Python 侧还要满足那条插件版本要求。 - 你写的不是 JavaScript → 先去
docs/src/test-assertions-csharp-java-python.md确认你要的那个机制在你的绑定里有没有。三类里只有”会重试的”这一类是四种绑定都写明的。
这个差异什么时候会咬到你:当你把一套 JS 的用例约定搬到 Python 团队,或者反过来。取反写法、软断言的可用性、轮询工具的有无,都不是”语法糖不同”,而是文档里就没写对应能力。照着 JS 的经验去 Python 里找 expect.poll,会浪费掉一个下午。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。
本文对照的是同一项目内的两种用法,依据均为上述仓库内容,不对两种用法做优劣排名, 选型结论只在仓库文档写明的能力边界内成立。