首页
/ Playwright 测试断言完全指南:expect 自动重试、软断言与自定义 Matcher 深度解析

Playwright 测试断言完全指南:expect 自动重试、软断言与自定义 Matcher 深度解析

2026-09-06 15:21:16作者:仰钰奇

Playwright Test 通过 expect 函数提供断言能力,其核心特色是面向 Web 页面的自动重试断言(auto-retrying assertions)——断言会持续重新获取元素并校验,直到条件满足或超时。本文以官方文档 test-assertions-js.md 为主线,完整覆盖断言分类、软断言、expect.poll / expect.toPass / expect.configure / expect.extend 等全部用法,并结合 expect 实现源码匹配器实现 深入剖析超时默认值、重试轮询间隔与失败处理机制,读完即可在测试项目中正确选择断言类型并编写可复用的自定义断言。

两种断言的基本形态

Playwright 内置两类断言:

  1. 通用断言(generic matchers):用于断言任意 JavaScript 值,如 toEqualtoContaintoBeTruthy,它们立即求值、不自动重试:
expect(success).toBeTruthy();

完整清单见 GenericAssertions API 参考

  1. 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

断言 说明
await expect(locator).toBeAttached() 元素已挂载到 DOM
await expect(locator).toBeChecked() 复选框已选中
await expect(locator).toBeDisabled() 元素被禁用
await expect(locator).toBeEditable() 元素可编辑
await expect(locator).toBeEmpty() 容器为空
await expect(locator).toBeEnabled() 元素可用
await expect(locator).toBeFocused() 元素获得焦点
await expect(locator).toBeHidden() 元素不可见
await expect(locator).toBeInViewport() 元素与视口相交
await expect(locator).toBeVisible() 元素可见
await expect(locator).toContainText() 元素包含指定文本
await expect(locator).toContainClass() 元素包含指定 CSS 类
await expect(locator).toHaveAccessibleDescription() 元素具有匹配的无障碍描述(accessible description)
await expect(locator).toHaveAccessibleName() 元素具有匹配的无障碍名称(accessible name)
await expect(locator).toHaveAttribute() 元素具有指定 DOM 属性
await expect(locator).toHaveClass() 元素的 class 属性完全匹配
await expect(locator).toHaveCount() Locator 列表的子元素数量精确匹配
await expect(locator).toHaveCSS() 元素具有指定 CSS 属性值
await expect(locator).toHaveId() 元素具有指定 ID
await expect(locator).toHaveJSProperty() 元素具有指定 JavaScript 属性
await expect(locator).toHaveRole() 元素具有指定 ARIA role
await expect(locator).toHaveScreenshot() 元素截图匹配
await expect(locator).toHaveText() 元素文本匹配
await expect(locator).toHaveValue() 输入框具有指定值
await expect(locator).toHaveValues() Select 选中了指定选项
await expect(locator).toMatchAriaSnapshot() 元素匹配 Aria 快照
await expect(page).toMatchAriaSnapshot() 页面匹配 Aria 快照
await expect(page).toHaveScreenshot() 页面截图匹配
await expect(page).toHaveTitle() 页面具有指定标题
await expect(page).toHaveURL() 页面 URL 匹配
await expect(response).toBeOK() 响应状态码为 2xx(OK)

从源码结构看,这些异步匹配器统一注册在 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.pollexpect.toPass

断言 说明
toBe 值完全相同(SameValue 语义)
toBeCloseTo 数值近似相等
toBeDefined 值不为 undefined
toBeFalsy 值为假值,如 false0null
toBeGreaterThan 数值大于
toBeGreaterThanOrEqual 数值大于或等于
toBeInstanceOf 对象是某个类的实例
toBeLessThan 数值小于
toBeLessThanOrEqual 数值小于或等于
toBeNaN 值为 NaN
toBeNull 值为 null
toBeTruthy 值为真值,即非 false0null
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 下自动挂载了它们的反向版本(arrayNotContainingstringNotMatching 等),因此 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 创建带有自定义默认值(如 timeoutsoftmessage)的 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 ?? 0L505),即若不传 timeout,默认立即判定"无时间预算";但可以在测试配置中通过 expect: { toPass: { timeout, intervals } } 设置项目级默认值(见 ExpectConfig.toPass 类型定义)。

使用 expect.extend 添加自定义匹配器

可以通过 expect.extendexpect 对象扩展自定义匹配器。自定义匹配器应返回一个包含 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.isNotthis.utils 可用),this.isNot 用于支持 .not 语义翻转;this.utils 提供 matcherHintprintExpectedprintReceived 等格式化工具,保证失败信息风格与内建断言一致。
  • 源码对 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 / ApiResponseAssertionsAPI 参考),默认等待 5 秒。
  • 验证普通 JS 值:使用通用匹配器,注意它们不重试;需要重试时改用 expect.poll(轮询一个函数)或 expect.toPass(重试整段代码块,注意其默认超时为 0)。
  • 一次检查多个互不依赖的页面状态:用 expect.softexpect.configure({ soft: true }) 收集全部失败而非首错即停。
  • 团队级统一调整:在 playwright.config.ts 中配置 expect: { timeout }expect: { toPass: { timeout, intervals } } 等(见 超时文档)。
  • 跨模块复用断言库:用 expect.extend 定义、mergeExpects 合并,并以 fixtures 文件统一导出。
登录后查看全文
热门项目推荐
相关项目推荐