Playwright Web-First 断言实战:PlaywrightAssertions 与 expect 的等待重试机制
本文基于 Playwright 官方 API 文档 class-playwrightassertions 展开,讲解 Playwright 的 Web-First Assertions(面向 Web 的自动重试断言):expect 如何对 Locator、Page、APIResponse 及任意值创建断言、默认 5 秒超时的等待重试行为从何而来,以及如何按语言(JavaScript / Python / Java / C#)正确使用 expect、assertThat、Expect 这几套入口。读完后你将能编写带等待语义的健壮断言,并理解其在测试运行器(trace、步骤记录)中的底层实现。
什么是 Web-First 断言
Playwright 的断言不是"取一次值立即判定",而是"持续重试直到条件满足或超时"。官方文档 PlaywrightAssertions 的原话是:
Playwright gives you Web-First Assertions with convenience methods for creating assertions that will wait and retry until the expected condition is met.
文档给出的标准示例(各语言写法)如下:
import { test, expect } from '@playwright/test';
test('status becomes submitted', async ({ page }) => {
// ...
await page.locator('#submit-button').click();
await expect(page.locator('.status')).toHaveText('Submitted');
});
from playwright.async_api import Page, expect
async def test_status_becomes_submitted(page: Page) -> None:
# ..
await page.locator("#submit-button").click()
await expect(page.locator(".status")).to_have_text("Submitted")
from playwright.sync_api import Page, expect
def test_status_becomes_submitted(page: Page) -> None:
# ..
page.locator("#submit-button").click()
expect(page.locator(".status")).to_have_text("Submitted")
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
public class TestExample {
// ...
@Test
void statusBecomesSubmitted() {
// ...
page.locator("#submit-button").click();
assertThat(page.locator(".status")).hasText("Submitted");
}
}
using Microsoft.Playwright;
using Microsoft.Playwright.MSTest;
namespace PlaywrightTests;
[TestClass]
public class ExampleTests : PageTest
{
[TestMethod]
public async Task StatusBecomesSubmitted()
{
await Page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();
await Expect(Page.Locator(".status")).ToHaveTextAsync("Submitted");
}
}
从源码结构看,这个"反复重取节点、反复检查"的行为发生在浏览器端:以 toHaveText 为例,packages/playwright/src/matchers/matchers.ts 中客户端断言把期望值序列化为 ExpectedTextValue(字符串或正则,附带 normalizeWhiteSpace、ignoreCase 等匹配选项)后,调用 locator._expect('to.have.text', { expectedText, isNot, timeout, ... }) 下发到浏览器侧执行;只要超时未满足,浏览器侧会不断重新查询节点并比对,直到返回 matches: true 或耗尽超时时间。
默认情况下,断言的超时时间为 5 秒,且可以在每次断言时通过 timeout 选项覆盖:
await expect(page.locator('.status')).toHaveText('Submitted', { timeout: 10_000 });
四类断言工厂方法:expectAPIResponse / expectGeneric / expectLocator / expectPage
PlaywrightAssertions 类本身没有直接暴露给业务代码,它是四类断言对象工厂方法的承载。文档 class-playwrightassertions.md 定义了 4 个方法 + 1 个 Java 专属的全局方法,各语言入口别名如下:
| 方法 | 作用 | JS/Python 入口 | Java 入口 | C# 入口 | 返回类型 | 引入版本 |
|---|---|---|---|---|---|---|
expectAPIResponse |
为 APIResponse 创建 APIResponseAssertions | expect |
assertThat |
Expect |
APIResponseAssertions |
v1.18 |
expectGeneric |
为任意值创建 GenericAssertions | expect(仅 JS) |
— | — | GenericAssertions |
v1.9 |
expectLocator |
为 Locator 创建 LocatorAssertions | expect |
assertThat |
Expect |
LocatorAssertions |
v1.18 |
expectPage |
为 Page 创建 PageAssertions | expect |
assertThat |
Expect |
PageAssertions |
v1.18 |
各入口的典型用法(继承自原文档):
// APIResponse 断言
PlaywrightAssertions.assertThat(response).isOK();
// Locator 断言
PlaywrightAssertions.assertThat(locator).isVisible();
// Page 断言
PlaywrightAssertions.assertThat(page).hasTitle("News");
await Expect(locator).ToBeVisibleAsync();
await Expect(Page).ToHaveTitleAsync("News");
JavaScript 与 Python 中入口统一为 expect,通过传入对象类型自动区分:
await expect(response).toBeOK(); // APIResponse
await expect(40 + 2).toBe(42); // 任意值(GenericAssertions,仅 JS)
await expect(page.locator('.status')).toHaveText('Submitted'); // Locator
await expect(page).toHaveTitle('News'); // Page
从源码结构可以印证这一分派逻辑:packages/playwright/src/matchers/matchers.ts 中 toBeOK 首先执行 expectTypes(response, ['APIResponse'], matcherName) 校验接收者类型;toHaveTitle、toHaveURL 作用于 Page(内部走 page.mainFrame()._expect('to.have.title', ...),见 matchers.ts);Locator 族断言则全部经由 locator._expect(expression, options) 与浏览器端交互。传入错误类型会直接得到清晰的类型错误提示,而不是隐晦的失败。
底层机制:超时、轮询与步骤记录
默认超时的取值链
在 packages/playwright/src/matchers/expect.ts 中定义了 const defaultExpectTimeout = 5000;,与文档"默认 5 秒"完全一致。每次断言执行时,invokeMatcher 按以下优先级确定生效超时:
info.timeout(单次 expect(..., { timeout }))
?? expectConfig().timeout(全局配置/用例级配置)
?? defaultExpectTimeout(5000ms)
其中 expectConfig() 是测试运行器注入的全局配置入口(expect.ts 中的 setExpectConfig/expectConfig)。
断言与测试超时的协调
deadlineForMatcher 会把"断言自己的超时"与"所在测试的剩余时间"取较小值作为最终截止时间(并预留 250ms 给测试收尾)。这意味着即使你把断言超时设得很大,它也不会超过测试本身的剩余时间,超时报错信息会区分是"断言超时"还是"测试超时"。
轮询间隔与 expect.poll
对于对任意值的轮询式断言 expect.poll,invokePollMatcher 采用递增间隔轮询,默认间隔为 [100, 250, 500, 1000] 毫秒(先 100ms 一次,再 250ms……),可通过 intervals 选项自定义:
await expect.poll(async () => await page.evaluate(() => document.title), {
timeout: 10_000,
intervals: [100, 500],
}).toBe('News');
注意 expect.poll 只接受函数作为第一个参数,且不支持与 Playwright 自定义的异步断言(如 toHaveText)组合,源码中会直接抛出 `expect.poll()` does not support "..." matcher. 提示。
每个断言都会成为 trace 中的一个步骤
在 callMatcherAsStep 中,每次断言都会向 testInfo._addStep 注册一个 category: 'expect' 的步骤(含 matcher 名称、Locator 描述、期望值等参数)。这就是为什么在 trace viewer 里可以逐条展开 Expect toHaveText("Submitted") 这样的步骤,并看到每次重试的日志——这是"等待重试"过程可观测的根源。
expect 对象的构造与扩展
最终导出的 expect 由 createExpect 构造(expect.ts 中 export const expect: Expect<{}> = createExpect({ userMatchers: {} });)。它一次性挂上了全部内建 matcher(allBuiltinMatchers,包括 toEqual、toBeTruthy 等 Jest 风格内建与 customAsyncMatchers 中的 toBeAttached、toBeChecked、toHaveText 等 Web 断言),并提供:
expect.not/resolves/rejects语义链(createMatchers为每个 matcher 生成正/反、Promise 变体);expect.extend扩展自定义 matcher(同名内建 matcher 只在新实例上被覆盖,不会污染全局);- 非对称匹配器辅助函数:
expect.stringContaining、expect.arrayContaining、expect.objectContaining、expect.closeTo等,及其expect.not反向版本。
const { expect, test } = require('@playwright/test');
const customMatchers = {
toBeEven(n) {
const pass = typeof n === 'number' && n % 2 === 0;
return { pass, message: () => `received: ${n}, expected even number` };
},
};
const extendedExpect = expect.extend(customMatchers);
await test.step('should be even', () => extendedExpect(4).toBeEven());
超时配置的三种方式
文档说明断言默认超时 5 秒,"You can pass this timeout as an option"。结合源码,完整配置面有三级:
-
单次断言选项:
{ timeout: 10000 }直接传给具体 matcher; -
Playwright 配置文件(JavaScript 测试运行器):在 config.ts 中,运行器从
project.expect与全局expect合并读取配置(takeFirst(projectConfig.expect, config.expect, {})),最终写入expectConfig().timeout。例如:// playwright.config.js export default { expect: { timeout: 10_000, toMatchSnapshot: { maxDiffPixels: 20 }, toHaveScreenshot: { threshold: 0.2 }, }, projects: [{ name: 'chromium', use: { browserName: 'chromium' }, expect: { timeout: 20_000 }, // 项目级可覆盖全局 }], };ExpectConfig接口(expect.ts)还暴露了toHaveScreenshot(threshold、maxDiffPixels、animations、stylePath 等)、toMatchSnapshot、toMatchAriaSnapshot、toPass(timeout、intervals)等子项,均可在配置文件中按项目统一收紧或放宽。 -
Java 全局方法:文档中的
setDefaultAssertionTimeout(v1.25 引入,仅 Java)把默认值从 5 秒改为指定毫秒数:PlaywrightAssertions.setDefaultAssertionTimeout(30_000);参数
timeout为 float,单位毫秒。
相关文档与测试验证
- 断言对象 API:LocatorAssertions(约 40 个 toHave*/toBe* matcher)、PageAssertions、APIResponseAssertions、GenericAssertions;
- 测试套件中针对 expect 行为的专项用例:expect-timeout.spec.ts、expect-misc.spec.ts、expect-to-have-text.spec.ts、expect-to-have-value.spec.ts、expect-builtins.spec.ts、expect-with-snapshot.spec.ts 等,可对照阅读以验证各断言的等待、超时与消息输出行为;
- 断言消息格式化(Expected/Received、Call Log 组装)实现在 matcherHint.ts。
小结
PlaywrightAssertions 是 Playwright 四语言断言体系的公共骨架:expect(JS/Python)、assertThat(Java)、Expect(C#)本质上都对应文档中的 expectGeneric/expectLocator/expectPage/expectAPIResponse 四个工厂方法,按传入对象类型分派到不同的断言对象。其"Web-First"特性由三层机制支撑——浏览器端可重试的 _expect 表达式执行(如 to.have.text)、客户端递增间隔轮询与测试超时协调(pollAgainstDeadline + deadlineForMatcher)、以及每个断言自动落为 trace 步骤。默认 5 秒超时、{ timeout } 选项、配置文件 expect 段与 Java 的 setDefaultAssertionTimeout 共同构成了完整的超时调节手段。掌握这套机制,你就能在异步 Web 界面上写出既不 flaky、又能精确定位失败原因的断言。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00