首页
/ Playwright Web-First 断言实战:PlaywrightAssertions 与 expect 的等待重试机制

Playwright Web-First 断言实战:PlaywrightAssertions 与 expect 的等待重试机制

2026-09-06 18:40:56作者:虞亚竹Luna

本文基于 Playwright 官方 API 文档 class-playwrightassertions 展开,讲解 Playwright 的 Web-First Assertions(面向 Web 的自动重试断言):expect 如何对 Locator、Page、APIResponse 及任意值创建断言、默认 5 秒超时的等待重试行为从何而来,以及如何按语言(JavaScript / Python / Java / C#)正确使用 expectassertThatExpect 这几套入口。读完后你将能编写带等待语义的健壮断言,并理解其在测试运行器(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(字符串或正则,附带 normalizeWhiteSpaceignoreCase 等匹配选项)后,调用 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.tstoBeOK 首先执行 expectTypes(response, ['APIResponse'], matcherName) 校验接收者类型;toHaveTitletoHaveURL 作用于 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.pollinvokePollMatcher 采用递增间隔轮询,默认间隔为 [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 对象的构造与扩展

最终导出的 expectcreateExpect 构造(expect.tsexport const expect: Expect<{}> = createExpect({ userMatchers: {} });)。它一次性挂上了全部内建 matcher(allBuiltinMatchers,包括 toEqualtoBeTruthy 等 Jest 风格内建与 customAsyncMatchers 中的 toBeAttachedtoBeCheckedtoHaveText 等 Web 断言),并提供:

  • expect.not / resolves / rejects 语义链(createMatchers 为每个 matcher 生成正/反、Promise 变体);
  • expect.extend 扩展自定义 matcher(同名内建 matcher 只在新实例上被覆盖,不会污染全局);
  • 非对称匹配器辅助函数:expect.stringContainingexpect.arrayContainingexpect.objectContainingexpect.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"。结合源码,完整配置面有三级:

  1. 单次断言选项{ timeout: 10000 } 直接传给具体 matcher;

  2. 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 等)、toMatchSnapshottoMatchAriaSnapshottoPass(timeout、intervals)等子项,均可在配置文件中按项目统一收紧或放宽。

  3. Java 全局方法:文档中的 setDefaultAssertionTimeout(v1.25 引入,仅 Java)把默认值从 5 秒改为指定毫秒数:

    PlaywrightAssertions.setDefaultAssertionTimeout(30_000);
    

    参数 timeout 为 float,单位毫秒。

相关文档与测试验证

小结

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、又能精确定位失败原因的断言。

登录后查看全文
热门项目推荐
相关项目推荐