首页 / Playwright 入门教程 / Web 优先断言 Assertions 全解

Playwright 入门教程

Web 优先断言 Assertions 全解

本教程共 59 篇 · 第 22 篇 · 更新于 2026-08-04 · 约 12 分钟阅读

Playwrightexpect断言自动重试软断言expect.polltoPass

22. Web 优先断言 Assertions 全解

本节目标:搞懂自动重试断言的机制,会挑合适的匹配器,并掌握软断言、poll、toPass 这几个进阶工具。

写测试的一半时间在操作页面,另一半在断言结果。

Playwright 的断言有个别处少见的特性:会自己重试。把这一点吃透,测试稳定性能上一个台阶。这章就讲它。

从一个老问题说起

传统写法是这样的:

// 老派做法
const text = await page.getByTestId('status').textContent();
expect(text).toBe('已提交');

问题在哪?textContent() 在那一瞬间取值。如果页面还在请求接口,取到的可能是空字符串,断言当场失败。

于是有人加 sleep(2000)。快的机器上浪费 2 秒,慢的机器上还是失败。这就是所谓的 flaky(不稳定)测试。

Playwright 的写法:

await expect(page.getByTestId('status')).toHaveText('已提交');

它不是取一次值就判断。它会反复重新查找元素、重新读取、重新比较,直到条件满足或超时。默认超时 5 秒。

一行代码,等待和断言合二为一。

Note

注意 await 的位置。自动重试断言是异步的,await 要放在 expect 前面:await expect(locator).toHaveText(...)。漏了 await,断言会变成一个没人管的 Promise,永远不会失败。

断言的三种形态

先建立分类,后面就不会用混:

类型写法是否重试用途
定位器断言await expect(locator).toBeVisible()页面元素状态
页面断言await expect(page).toHaveTitle(...)页面级属性
通用断言expect(value).toBe(...)普通 JS 值

前两种是「Web 优先断言」,也是这章的主角。第三种和 Jest 那套一样,同步执行。

常用匹配器速查

定位器断言的匹配器不少,按用途分组记比较容易。

可见性与存在性

await expect(locator).toBeVisible();     // 可见
await expect(locator).toBeHidden();      // 不可见
await expect(locator).toBeAttached();    // 已挂到 DOM(不要求可见)
await expect(locator).toBeInViewport();  // 在视口内

toBeHidden()not.toBeVisible() 的判定结果其实一致:元素不存在,或者存在但不可见,两种情况都算通过。挑哪个看哪句读起来更顺。

toBeAttached() 是个容易被忽略的好东西。元素挂进 DOM 了但还没显示出来,用它断言正合适。

状态类

await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeEditable();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
await expect(locator).toBeEmpty();       // 容器内没有文本和子元素

内容类

await expect(locator).toHaveText('完全匹配');
await expect(locator).toContainText('包含即可');
await expect(locator).toHaveText(/正则也行/);
await expect(locator).toHaveValue('输入框的值');
await expect(locator).toHaveValues(['a', 'b']);  // 多选下拉的选中项

toHaveText 是完全匹配(会去掉首尾空白),toContainText 是包含匹配。文案里有动态内容(时间、数字)时,后者更实用。

传数组给 toHaveText,可以一次断言一组元素:

await expect(page.getByRole('listitem')).toHaveText(['苹果', '香蕉', '橙子']);

属性类

await expect(locator).toHaveAttribute('href', '/home');
await expect(locator).toHaveClass('active');       // 完全匹配 class 属性
await expect(locator).toContainClass('active');    // 包含某个 class
await expect(locator).toHaveId('submit-btn');
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(locator).toHaveJSProperty('checked', true);
await expect(locator).toHaveRole('button');

toContainClass()toHaveClass() 好用得多。现代框架的 class 列表又长又乱,完全匹配基本没法维护。

数量与页面级

await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page).toHaveTitle('首页 - 示例站');
await expect(page).toHaveURL(/\/dashboard/);

// 接口响应断言,要求状态码是 2xx
const apiResponse = await page.request.get('/api/health');
await expect(apiResponse).toBeOK();

可访问性相关

await expect(locator).toHaveAccessibleName('提交订单');
await expect(locator).toHaveAccessibleDescription('提交后不可修改');
await expect(locator).toMatchAriaSnapshot(`- button "提交"`);

非重试断言:什么时候用

处理普通 JS 值时,用通用断言:

expect(1 + 1).toBe(2);
expect([1, 2, 3]).toContain(2);
expect({ a: 1 }).toEqual({ a: 1 });
expect('hello').toMatch(/ell/);
expect(list).toHaveLength(3);
expect(() => risky()).toThrow();

这些不重试,立刻判断。

Warning

别用通用断言检查页面状态。像 expect(await locator.isVisible()).toBe(true) 这种写法,等于把自动重试关掉了,测试立刻变脆。能用 Web 优先断言的地方就用它。

否定断言:.not

在匹配器前面加 .not

expect(value).not.toEqual(0);
await expect(locator).not.toContainText('错误');
await expect(locator).not.toBeVisible();

否定断言的重试逻辑是反过来的:它会一直重试,直到条件不成立。所以 not.toBeVisible() 会等元素消失,而不是立刻判断。

软断言:expect.soft

默认情况下,断言一失败,测试立刻中止,后面的代码不再执行。

有时你希望「先都检查一遍,再一起看结果」。用 expect.soft

// 失败也继续往下跑
await expect.soft(page.getByTestId('status')).toHaveText('成功');
await expect.soft(page.getByTestId('eta')).toHaveText('1 天');

// 后面的操作照常执行
await page.getByRole('link', { name: '下一页' }).click();
await expect.soft(page.getByRole('heading', { name: '再下一单' })).toBeVisible();

软断言失败会把测试标记为失败,但不打断执行。报告里能一次看到所有问题,不用改一个跑一遍。

想在某个节点检查前面有没有软断言失败过:

// 有失败就不往下走了
expect(test.info().errors).toHaveLength(0);
Note

软断言只在 Playwright Test(测试运行器)里可用。库模式(第 53 章)下直接调 API,没有这个功能。

自定义失败消息

expect 的第二个参数可以写一句人话:

await expect(page.getByText('用户名'), '登录后应显示用户名').toBeVisible();

这句话会进到报告里,成功失败都显示。失败时长这样:

Error: 登录后应显示用户名

Call log:
  - expect.toBeVisible with timeout 5000ms
  - waiting for "getByText('用户名')"

一堆断言堆在一起时,这句话能省下不少定位时间。软断言同样支持:

expect.soft(value, '金额应为 56').toBe(56);

自定义超时与 expect.configure

单个断言改超时:

await expect(locator).toHaveText('提交', { timeout: 10_000 });

全局改,在配置文件里:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    timeout: 10_000,
  },
});

想造一个「预设了某些选项」的 expect 实例,用 expect.configure()

// 一个更有耐心的 expect
const slowExpect = expect.configure({ timeout: 10_000 });
await slowExpect(locator).toHaveText('提交');

// 一个默认软断言的 expect
const softExpect = expect.configure({ soft: true });
await softExpect(locator).toHaveText('提交');

expect.poll:把任意函数变成可重试断言

有些东西不是页面元素,但也需要「等它变成某个值」。比如轮询一个接口:

await expect.poll(async () => {
  const response = await page.request.get('https://api.example.com/status');
  return response.status();
}, {
  message: '接口最终应返回 200',
  timeout: 10_000,       // 默认 5 秒,传 0 表示不超时
}).toBe(200);

expect.poll 反复调用那个函数,把返回值交给后面的匹配器判断,直到通过或超时。

轮询间隔可以自定义:

await expect.poll(async () => {
  const response = await page.request.get('https://api.example.com');
  return response.status();
}, {
  // 探测、等 1 秒、探测、等 2 秒、探测、之后每 10 秒一次
  // 默认是 [100, 250, 500, 1000]
  intervals: [1_000, 2_000, 10_000],
  timeout: 60_000,
}).toBe(200);

它也能和软断言组合:

await expect.soft.poll(async () => {
  const response = await page.request.get('https://api.example.com');
  return response.status();
}).toBe(200);

expect.toPass:整段代码重试

expect.poll 重试的是「取值」。如果你要重试的是一整段逻辑,用 toPass

await expect(async () => {
  const response = await page.request.get('https://api.example.com');
  expect(response.status()).toBe(200);
}).toPass();

整个回调会被反复执行,直到里面所有断言都通过。

await expect(async () => {
  // ... 复杂的多步检查
}).toPass({
  intervals: [1_000, 2_000, 10_000],
  timeout: 60_000,
});
Warning

toPass 的默认超时是 0,也就是不限时,而且它不遵守配置里的 expect 超时。写的时候记得手动给 timeout,否则代码有 bug 时会一直转到测试整体超时。

两者怎么选?取一个值再判断,用 poll;需要多个断言配合,用 toPass

一段完整示例

import { test, expect } from '@playwright/test';

test('下单流程结果校验', async ({ page }) => {
  await page.goto('https://example.com/checkout');
  await page.getByRole('button', { name: '提交订单' }).click();

  // 硬断言:这个不过后面就没意义了
  await expect(page).toHaveURL(/\/orders\/\d+/);

  // 软断言:把细节一次性都查了
  await expect.soft(page.getByTestId('order-status'), '订单状态应为已支付')
    .toHaveText('已支付');
  await expect.soft(page.getByTestId('order-amount')).toContainText('¥');
  await expect.soft(page.getByRole('listitem')).toHaveCount(3);

  // 等后台异步处理完成
  await expect.poll(async () => {
    const res = await page.request.get('/api/order/latest');
    return (await res.json()).shipped;
  }, { timeout: 15_000 }).toBe(true);
});

小结

  • Web 优先断言自带重试,是消灭 flaky 测试的核心武器,一定要 await
  • 断言默认超时 5 秒,独立于测试超时(30 秒),可单独或全局配置。
  • 内容匹配优先 toContainText,class 匹配优先 toContainClass
  • 别把页面状态取出来用通用断言判断,那等于关掉了重试。
  • expect.soft 让你一次收集多个失败;配 test.info().errors 可以在关键点刹车。
  • 给断言写一句自定义消息,排查时能省很多事。
  • 非元素的等待用 expect.poll,整段逻辑重试用 expect.toPass(记得给 timeout)。

下一章预告:超时、重试与显式等待策略——七级超时怎么分工、各种 waitFor 怎么挑,以及为什么别写 sleep