Playwright 测试断言完全指南:expect 自动重试、软断言与自定义 Matcher 深度解析
Playwright Test 通过 expect 函数提供断言能力,其核心特色是面向 Web 页面的自动重试断言(auto-retrying assertions)——断言会持续重新获取元素并校验,直到条件满足或超时。本文以官方文档 test-assertions-js.md 为主线,完整覆盖断言分类、软断言、expect.poll / expect.toPass / expect.configure / expect.extend 等全部用法,并结合 expect 实现源码 与 匹配器实现 深入剖析超时默认值、重试轮询间隔与失败处理机制,读完即可在测试项目中正确选择断言类型并编写可复用的自定义断言。
两种断言的基本形态
Playwright 内置两类断言:
- 通用断言(generic matchers):用于断言任意 JavaScript 值,如
toEqual、toContain、toBeTruthy,它们立即求值、不自动重试:
expect(success).toBeTruthy();
完整清单见 GenericAssertions API 参考。
- Web 专用异步断言(async matchers):作用于
Locator/Page/APIResponse,会等待直到预期条件成立。例如:
await expect(page.getByTestId('status')).toHaveText('Submitted');
Playwright 会反复重新获取 test id 为 status 的元素并检查其文本,直到文本变为 "Submitted" 或超时为止。可以推断,这种"重新获取 + 重新校验"的循环正是其对抗 Web 异步渲染(数据延迟加载、DOM 更新抖动)的核心手段——断言失败不代表立即报错,而是在超时窗口内持续重试。
断言的超时可以针对单次断言传入,也可以通过测试配置中的 expect 属性一次性设置。默认断言超时为 5 秒,各级超时的完整关系见 超时(Timeouts)文档。
源码印证:在 expect.ts 中定义了 const defaultExpectTimeout = 5000;,且实际超时取值遵循"单次传入 → 实例配置 → 全局配置 → 默认 5000ms"的优先级链(见 expect.ts 中 invokeMatcher 中的 info.timeout ?? expectConfig().timeout ?? defaultExpectTimeout)。
自动重试断言(Auto-retrying assertions)
以下断言会重试直到通过,或达到断言超时。注意:重试型断言是异步的,必须 await。
从源码结构看,这些异步匹配器统一注册在 expect.ts 的 customAsyncMatchers 中,具体实现位于 matchers.ts 等文件,例如 toHaveURL 会通过 page.mainFrame()._expect('to.have.url', ...) 将校验下推到浏览器侧执行(见 matchers.ts#L459-L467),这正是"重新获取元素并校验"能够跨浏览器(Chromium / Firefox / WebKit)一致工作的原因。
断言超时的错误形态(来自 超时文档):
Error: expect(received).toHaveText(expected)
Expected string: "my text"
Received string: ""
Call log:
- expect.toHaveText with timeout 5000ms
- waiting for "locator('button')"
非重试断言(Non-retrying assertions)
非重试断言用于测试任意条件,但不会自动重试。Web 页面中的信息通常异步呈现,使用非重试断言容易产出 flaky 测试。官方建议:尽可能优先使用自动重试断言;对确实需要重试的复杂断言,使用 expect.poll 或 expect.toPass。
| 断言 | 说明 |
|---|---|
toBe |
值完全相同(SameValue 语义) |
toBeCloseTo |
数值近似相等 |
toBeDefined |
值不为 undefined |
toBeFalsy |
值为假值,如 false、0、null 等 |
toBeGreaterThan |
数值大于 |
toBeGreaterThanOrEqual |
数值大于或等于 |
toBeInstanceOf |
对象是某个类的实例 |
toBeLessThan |
数值小于 |
toBeLessThanOrEqual |
数值小于或等于 |
toBeNaN |
值为 NaN |
toBeNull |
值为 null |
toBeTruthy |
值为真值,即非 false、0、null 等 |
toBeUndefined |
值为 undefined |
toContain |
字符串包含子串(或数组/集合包含元素) |
toContainEqual |
数组或集合包含相似元素 |
toEqual |
深度相等(deep equality)并支持模式匹配 |
toHaveLength |
数组或字符串具有指定长度 |
toHaveProperty |
对象具有指定属性 |
toMatch |
字符串匹配正则表达式 |
toMatchObject |
对象包含指定属性 |
toStrictEqual |
相等且包括属性类型 |
toThrow |
函数抛出错误 |
完整签名与参数说明见 GenericAssertions API 参考。
非对称匹配器(Asymmetric matchers)
这些匹配器表达式可以嵌套在其他断言内部,用于对给定条件进行更宽松的匹配:
| 匹配器 | 说明 |
|---|---|
expect.any() |
匹配某个类/基本类型的任意实例 |
expect.anything() |
匹配任意非 null/undefined 值 |
expect.arrayContaining() |
数组包含指定元素 |
expect.arrayOf() |
数组元素均为指定类型 |
expect.closeTo() |
数值近似相等 |
expect.objectContaining() |
对象包含指定属性 |
expect.stringContaining() |
字符串包含子串 |
expect.stringMatching() |
字符串匹配正则表达式 |
源码印证:这些工厂函数在 expect.ts 的 createExpect 中被逐一挂到 expect 对象上(expectFn.any = any; expectFn.stringMatching = stringMatching; ...),并且 expect.not 下自动挂载了它们的反向版本(arrayNotContaining、stringNotMatching 等),因此 expect.not.stringMatching(/x/) 这类写法是内建支持的。
否定断言(Negating matchers)
在匹配器前加 .not 即可断言相反情况:
expect(value).not.toEqual(0);
await expect(locator).not.toContainText('some text');
从 createMatchers 的实现可以看到,每个匹配器都会同时以"正向 info"和"isNot 翻转后的 info"各生成一份绑定(result[name] 与 result.not[name]),.not 只是切换了内部的 isNot 标志,匹配器逻辑本身不变。
软断言(Soft assertions)
默认情况下,失败的断言会立即终止测试执行。Playwright 还支持软断言(soft assertions):失败的软断言不会终止测试,而是将测试标记为失败,测试可以继续执行后续步骤。
// 做一些失败后不中断测试的检查...
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await expect.soft(page.getByTestId('eta')).toHaveText('1 day');
// ... 继续测试,检查更多内容。
await page.getByRole('link', { name: 'next page' }).click();
await expect.soft(page.getByRole('heading', { name: 'Make another order' })).toBeVisible();
在测试执行的任何时点,都可以检查是否已有软断言失败:
// 做一些失败后不中断测试的检查...
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await expect.soft(page.getByTestId('eta')).toHaveText('1 day');
// 如果已有软断言失败,就不再继续执行。
expect(test.info().errors).toHaveLength(0);
注意:软断言仅在 Playwright 测试运行器(Playwright Test)中生效——它依赖测试运行器的 step 机制来记录错误。
源码印证:expect.soft 是一个惰性 getter,见 expect.ts#L268-L272,它克隆当前实例并置 isSoft: true。失败时的处理分支在 callMatcherAsStep 的 handleError 中:若 info.isSoft 为真,错误通过 step.complete({ ..., softError: error }) 记录到 step 而不抛出;否则 throw error 直接中断测试。
自定义 expect 消息
expect 函数接受第二个参数作为自定义消息,它会显示在各类 reporter 中(无论断言成功还是失败),提供更多断言上下文:
await expect(page.getByText('Name'), 'should be logged in').toBeVisible();
当断言通过时,你会看到类似这样的成功步骤:
✅ should be logged in @example.spec.ts:18
当断言失败时,错误输出形如:
Error: should be logged in
Call log:
- expect.toBeVisible with timeout 5000ms
- waiting for "getByText('Name')"
2 |
3 | test('example test', async({ page }) => {
> 4 | await expect(page.getByText('Name'), 'should be logged in').toBeVisible();
| ^
5 | });
6 |
源码印证:自定义消息的拼接逻辑在 callMatcherAsStep 中——若提供了 customMessage,step 标题直接使用它;否则自动生成为 Expect "toBeVisible(getByText('Name'))" 这样的描述性标题。软断言同样支持自定义消息:
expect.soft(value, 'my soft assertion').toBe(56);
expect.configure:预配置的 expect 实例
可以用 expect.configure 创建带有自定义默认值(如 timeout、soft、message)的 expect 实例:
const slowExpect = expect.configure({ timeout: 10000 });
await slowExpect(locator).toHaveText('Submit');
// 始终使用软断言。
const softExpect = expect.configure({ soft: true });
await softExpect(locator).toHaveText('Submit');
实现上(见 expect.ts#L257-L266),configure 复制当前实例的元信息并覆盖指定字段后调用 createExpect(newInfo),返回的是一个新的不可变实例,不影响全局 expect。这意味着你可以把 softExpect 封装进 fixture 模块,在整个测试目录中复用。
expect.poll:把任意同步断言变成异步轮询
expect.poll 可以把任意同步 expect 转换为异步轮询断言:反复调用给定函数,直到内部断言通过或超时。下面的示例轮询直到接口返回 HTTP 200:
await expect.poll(async () => {
const response = await page.request.get('https://api.example.com');
return response.status();
}, {
// 自定义 expect 消息,可选。
message: 'make sure API eventually succeeds',
// 轮询 10 秒;默认 5 秒。传 0 可禁用超时。
timeout: 10000,
}).toBe(200);
还可以指定自定义轮询间隔:
await expect.poll(async () => {
const response = await page.request.get('https://api.example.com');
return response.status();
}, {
// 探测,等 1s,探测,等 2s,探测,等 10s,探测,等 10s,探测...
// 默认间隔为 [100, 250, 500, 1000]。
intervals: [1_000, 2_000, 10_000],
timeout: 60_000
}).toBe(200);
expect.soft 可以与 expect.poll 组合,实现轮询逻辑中的软断言——即使 poll 内的断言最终失败,测试也能继续:
await expect.soft.poll(async () => {
const response = await page.request.get('https://api.example.com');
return response.status();
}).toBe(200);
expect.configure({ soft: true }) 的实例也可以与 expect.poll 链接,方便复用预配置实例:
const softExpect = expect.configure({ soft: true });
await softExpect.poll(async () => {
const response = await page.request.get('https://api.example.com');
return response.status();
}).toBe(200);
源码印证:轮询的核心在 invokePollMatcher——它基于 pollAgainstDeadline 在截止时间内循环执行"调用函数 → 跑一次断言 → 失败则按 intervals 等待后重试";默认间隔 [100, 250, 500, 1000] 硬编码在 expect.ts#L452。另外源码明确限制:expect.poll() 只接受函数作为第一参数,且不支持内置的异步 Web 匹配器(如 toHaveText)——因为那些匹配器本身已自带重试。
expect.toPass:重试一段代码块直到成功
对于"多步骤复合逻辑",可以用 expect.toPass 重试整段回调,直到其中所有断言全部通过:
await expect(async () => {
const response = await page.request.get('https://api.example.com');
expect(response.status()).toBe(200);
}).toPass();
同样支持自定义超时与重试间隔:
await expect(async () => {
const response = await page.request.get('https://api.example.com');
expect(response.status()).toBe(200);
}).toPass({
// 探测,等 1s,探测,等 2s,探测,等 10s,探测,等 10s,探测...
// 默认间隔为 [100, 250, 500, 1000]。
intervals: [1_000, 2_000, 10_000],
timeout: 60_000
});
注意一个容易踩坑的细节:toPass 默认超时为 0,且默认不遵循配置里的自定义 expect 超时。源码印证:matchers.ts 的 toPass 实现 中,timeout = options.timeout ?? expectConfig().toPass?.timeout ?? 0(L505),即若不传 timeout,默认立即判定"无时间预算";但可以在测试配置中通过 expect: { toPass: { timeout, intervals } } 设置项目级默认值(见 ExpectConfig.toPass 类型定义)。
使用 expect.extend 添加自定义匹配器
可以通过 expect.extend 向 expect 对象扩展自定义匹配器。自定义匹配器应返回一个包含 pass 标志(断言是否通过)和 message 回调(断言失败时生成错误信息)的对象。
下面的示例新增一个 toHaveAmount 匹配器,它复用内置的 toHaveAttribute 做底层重试校验:
import { expect as baseExpect } from '@playwright/test';
import type { Locator } from '@playwright/test';
export { test } from '@playwright/test';
export const expect = baseExpect.extend({
async toHaveAmount(locator: Locator, expected: number, options?: { timeout?: number }) {
const assertionName = 'toHaveAmount';
let pass: boolean;
let matcherResult: any;
try {
const expectation = this.isNot ? baseExpect(locator).not : baseExpect(locator);
await expectation.toHaveAttribute('data-amount', String(expected), options);
pass = true;
} catch (e: any) {
matcherResult = e.matcherResult;
pass = false;
}
if (this.isNot) {
pass = !pass;
}
const message = pass
? () => this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) +
'\n\n' +
`Locator: ${locator}\n` +
`Expected: not ${this.utils.printExpected(expected)}\n` +
(matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '')
: () => this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) +
'\n\n' +
`Locator: ${locator}\n` +
`Expected: ${this.utils.printExpected(expected)}\n` +
(matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '');
return {
message,
pass,
name: assertionName,
expected,
actual: matcherResult?.actual,
};
},
});
之后即可在测试中使用 toHaveAmount:
import { test, expect } from './fixtures';
test('amount', async () => {
await expect(page.locator('.cart')).toHaveAmount(4);
});
要点说明(结合 expect.extend 源码):
- 匹配器以方法形式绑定调用(
this.isNot、this.utils可用),this.isNot用于支持.not语义翻转;this.utils提供matcherHint、printExpected、printReceived等格式化工具,保证失败信息风格与内建断言一致。 - 源码对
expect.extend采用"返回新实例"语义:新匹配器注册到返回的 expect 上,不覆盖内建匹配器名(覆盖内建名的行为只作用于返回实例)。 expect从@playwright/test导出(见 index.ts#L44),mergeExpects同处导出(index.ts#L935)。
与 expect 库的兼容性
注意:不要将 Playwright 的 expect 与独立的 expect 库 混淆。后者没有与 Playwright 测试运行器完全集成,请务必使用 Playwright 自带的 expect。
合并多个模块的自定义匹配器
可以将多个文件/模块中的自定义匹配器合并到一起:
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as dbTest, expect as dbExpect } from 'database-test-utils';
import { test as a11yTest, expect as a11yExpect } from 'a11y-test-utils';
export const expect = mergeExpects(dbExpect, a11yExpect);
export const test = mergeTests(dbTest, a11yTest);
import { test, expect } from './fixtures';
test('passes', async ({ database }) => {
await expect(database).toHaveDatabaseUser('admin');
});
mergeExpects 的实现 会从每个传入实例中提取其用户匹配器(通过内部 META_INFO 符号读取 userMatchers)并逐个 extend 到基础 expect 上;对非 Playwright 系(即直接 mutate 全局 expect 的第三方)实例则直接跳过处理。
小结:选型与超时决策
- 验证页面状态(可见性、文本、属性、URL、标题、响应状态):一律使用自动重试的
LocatorAssertions/PageAssertions/ApiResponseAssertions(API 参考),默认等待 5 秒。 - 验证普通 JS 值:使用通用匹配器,注意它们不重试;需要重试时改用
expect.poll(轮询一个函数)或expect.toPass(重试整段代码块,注意其默认超时为 0)。 - 一次检查多个互不依赖的页面状态:用
expect.soft或expect.configure({ soft: true })收集全部失败而非首错即停。 - 团队级统一调整:在
playwright.config.ts中配置expect: { timeout }、expect: { toPass: { timeout, intervals } }等(见 超时文档)。 - 跨模块复用断言库:用
expect.extend定义、mergeExpects合并,并以 fixtures 文件统一导出。
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 StartedRust0623
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