首页 / Playwright 入门教程 / 超时、重试与显式等待策略

Playwright 入门教程

超时、重试与显式等待策略

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

Playwright超时waitFor显式等待自动等待flakytest.slow

23. 超时、重试与显式等待策略

本节目标:分清 Playwright 各级超时的作用范围,会在自动等待兜不住时挑对显式等待的方法。

「测试跑起来时好时坏」——这句话背后,九成是等待没处理对。

Playwright 已经把大部分等待自动化了。但总有它猜不到的场景,需要你手动补。这章先把超时体系理清楚,再讲什么时候该显式等待、用哪一个。

超时分几级

Playwright Test 有一整套独立的超时,互不干扰。先看总表:

超时类型默认值管什么
测试超时30 秒单个测试函数整体
断言超时5 秒单个自动重试断言
操作超时单次 click / fill
导航超时单次 goto / 跳转
beforeAll / afterAll 超时30 秒单个钩子
夹具超时单个夹具(Fixture)
全局超时整轮测试运行

「无」表示不单独限时,最终受测试超时约束。

新手最容易混的是前两个。测试超时 30 秒,断言超时 5 秒,两者完全独立。 一个测试里有 10 个断言,每个最多等 5 秒,但整体不能超过 30 秒。

测试超时

超时报错长这样:

example.spec.ts:3:1 › basic test ===========================
Timeout of 30000ms exceeded.

计入测试超时的部分:测试函数本体、夹具的初始化、beforeEach 钩子。

夹具的清理和 afterEach 另算一份同样长度的时间,不和测试抢。

配置方式有三种:

// 1. 全局,playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  timeout: 120_000,
});
// 2. 单个测试
test('很慢的测试', async ({ page }) => {
  test.setTimeout(120_000);
  // ...
});

test('稍微慢一点', async ({ page }) => {
  test.slow();  // 一句话把默认超时乘以 3
  // ...
});
// 3. 在 beforeEach 里给这一组都加时间
test.beforeEach(async ({ page }, testInfo) => {
  testInfo.setTimeout(testInfo.timeout + 30_000);
});

test.slow() 挺好用。不用算具体数字,标一下就行。

断言超时

超时报错会把 Call log 一起打出来:

Error: expect(received).toHaveText(expected)
Expected string: "我的文字"
Received string: ""

Call log:
  - expect.toHaveText with timeout 5000ms
  - waiting for "locator('button')"

调整方式:

// 全局,playwright.config.ts
export default defineConfig({
  expect: { timeout: 10_000 },
});
// 单条断言
await expect(locator).toHaveText('hello', { timeout: 10_000 });

操作与导航超时

这两个默认不限时,靠测试超时兜底。

想单独限制,在配置的 use 里设:

// playwright.config.ts
export default defineConfig({
  use: {
    actionTimeout: 10_000,
    navigationTimeout: 30_000,
  },
});

单次覆盖:

await page.goto('https://example.com', { timeout: 30_000 });
await page.getByText('开始').click({ timeout: 10_000 });
Tip

官方的态度是:这些底层超时一般不用动。如果你在翻这一节是因为测试不稳,问题多半在别处——定位器不精确、缺显式等待、或者应用本身有竞态。

全局超时

限制整轮测试的总时长,防止 CI 上卡死烧机器:

// playwright.config.ts
export default defineConfig({
  globalTimeout: 3_600_000,  // 1 小时
});

超时输出:

Running 1000 tests using 10 workers
  514 skipped
  486 passed
  Timed out waiting 3600s for the entire test run

夹具超时

慢的 Fixture(夹具,比如要启动一个服务)可以单独给时间,不占测试额度:

import { test as base } from '@playwright/test';

export const test = base.extend<{ slowFixture: string }>({
  slowFixture: [async ({}, use) => {
    // 一些很慢的准备工作
    await use('hello');
  }, { timeout: 60_000 }],
});

什么时候需要显式等待

先说结论:大多数时候不需要

Playwright 的自动等待覆盖了两大块:

  1. 操作前的可操作性检查(第 17 章)——等元素可见、稳定、能收事件、启用。
  2. 断言的自动重试(第 22 章)——等条件成立。

这两块加起来,日常 80% 的等待需求就没了。

剩下 20% 是这些情况:

  • 等某个不参与断言的中间状态(比如加载动画消失)。
  • 等 URL 跳到某个地址。
  • 等一个网络请求或响应。
  • 等页面里某个 JS 变量变成特定值。
  • 等一个事件(弹窗、下载、新标签页)。

locator.waitFor:等元素到某个状态

// 等元素可见(默认)
await page.getByTestId('result').waitFor();

// 等元素消失
await page.getByTestId('loading-spinner').waitFor({ state: 'hidden' });

// 等元素挂到 DOM(不要求可见)
await page.getByTestId('lazy-block').waitFor({ state: 'attached' });

// 等元素从 DOM 移除
await page.getByTestId('toast').waitFor({ state: 'detached' });

// 自定义超时
await page.getByTestId('result').waitFor({ timeout: 15_000 });

四种状态记一下:visiblehiddenattacheddetached

最常见的用法是等遮罩层消失:

await page.getByRole('button', { name: '查询' }).click();
await page.getByTestId('loading-mask').waitFor({ state: 'hidden' });
await expect(page.getByRole('table')).toBeVisible();
Note

能用断言表达的,优先用断言。await expect(locator).toBeVisible()await locator.waitFor() 效果接近,但断言失败时的报错信息更完整,也会计入报告。waitFor 更适合「只是要等一下,不算一条检查项」的场合。

page.waitForURL:等地址变化

// 等跳到指定地址
await page.waitForURL('https://example.com/dashboard');

// 支持通配符
await page.waitForURL('**/dashboard');

// 支持正则
await page.waitForURL(/\/orders\/\d+/);

// 等到网络空闲再算数
await page.waitForURL('**/dashboard', { waitUntil: 'networkidle' });

登录后跳转是典型场景:

await page.getByRole('button', { name: '登录' }).click();
await page.waitForURL('**/dashboard');
await expect(page.getByText('欢迎回来')).toBeVisible();

page.waitForLoadState:等加载阶段

await page.waitForLoadState();                    // 默认 'load'
await page.waitForLoadState('domcontentloaded');  // DOM 解析完
await page.waitForLoadState('networkidle');       // 网络安静下来

三个阶段的含义:

  • domcontentloaded:HTML 解析完,图片样式可能还在下。
  • loadload 事件触发,所有资源加载完。
  • networkidle:至少 500 毫秒内没有新的网络连接。

page.goto() 默认已经等到 load,所以多数时候不用手动调。

Warning

networkidle 看着很美,实则不建议常用。带轮询、长连接、埋点上报的页面永远「不安静」,这一等就是超时。官方也明确不推荐把它当默认等待手段。

page.waitForFunction:等 JS 条件成立

在页面里反复执行一个函数,直到返回真值:

// 等某个全局变量就绪。window 上的自定义属性 TS 不认识,断言一下
await page.waitForFunction(() => (window as any).appReady === true);

// 等元素数量达到预期
await page.waitForFunction(() => document.querySelectorAll('.item').length >= 10);

// 传参进去
const target = 20;
await page.waitForFunction(
  expected => document.querySelectorAll('.item').length >= expected,
  target,
);

它默认按 requestAnimationFrame 的节奏轮询,也可以改成固定间隔:

await page.waitForFunction(() => (window as any).dataLoaded, null, { polling: 1000 });

这是个万能兜底,但也意味着测试开始依赖内部实现。能用可见状态表达的,就别掏这个。

等事件:waitForEvent 家族

第 20、21 章已经用过这个模式:

// 等新标签页
const pagePromise = context.waitForEvent('page');
await page.getByText('新窗口打开').click();
const newPage = await pagePromise;

// 等下载
const downloadPromise = page.waitForEvent('download');
await page.getByText('下载').click();
const download = await downloadPromise;

// 等弹出窗口
const popupPromise = page.waitForEvent('popup');
await page.getByText('打开弹窗').click();
const popup = await popupPromise;

网络方向有两个专用方法:

// 等某个请求发出
const requestPromise = page.waitForRequest('**/api/search*');
await page.getByRole('button', { name: '搜索' }).click();
const request = await requestPromise;

// 等某个响应回来
const responsePromise = page.waitForResponse(
  res => res.url().includes('/api/search') && res.status() === 200,
);
await page.getByRole('button', { name: '搜索' }).click();
const response = await responsePromise;
console.log(await response.json());

再强调一次那个顺序规则:

Warning

先挂等待(不加 await),再触发动作,最后 await Promise。

反过来写——先点击再等事件——会漏掉在这中间已经发生的事件,运气不好就永久卡住。

waitForTimeout:能不用就不用

死等固定毫秒数:

await page.waitForTimeout(2000);  // 干等 2 秒

它的问题很直白:

  1. 快的时候浪费时间,一百个测试就是几分钟。
  2. 慢的时候还是不够,照样失败。
  3. 掩盖了真实问题,让人以为「加个等待就好了」。

官方在这个 API 的文档里写得很直白:生产环境的测试不要用它,靠时间等待的测试天生就是脆的。

那什么时候能用?我自己只在两种场合用过:

  • 本地临时调试,想看清页面某一刻的样子。
  • 确实要测「停留 N 秒后自动触发」的逻辑——但这种场景更好的选择是时钟模拟(第 29 章)。

一张决策表

遇到「这里要等一下」,照这个顺序想:

  1. 能不能靠自动等待?——直接写操作或断言,先跑一次看看。
  2. 是等元素状态?——expect(locator).toBeVisible()locator.waitFor()
  3. 是等页面跳转?——page.waitForURL()
  4. 是等接口?——page.waitForResponse()
  5. 是等事件?——page.waitForEvent()
  6. 是等复杂条件?——expect.poll()expect.toPass()
  7. 是等 JS 内部状态?——page.waitForFunction()
  8. 以上都不行——才考虑 waitForTimeout(),并留个注释说明原因。

小结

  • 超时分七级,最常打交道的是测试超时(30 秒)和断言超时(5 秒),两者独立。
  • 单测试放宽用 test.slow()test.setTimeout(),全局在配置里改。
  • 操作和导航默认不限时,靠测试超时兜底,一般不用单独配。
  • 自动等待能覆盖大部分场景,显式等待是补充不是主力。
  • locator.waitFor 等元素状态,waitForURL 等跳转,waitForResponse 等接口,waitForEvent 等事件。
  • networkidle 在有轮询的页面上会卡死,慎用。
  • 事件等待永远是「先挂 Promise 再触发动作」。
  • waitForTimeout 是最后手段,用了就等于埋了个雷。

下一章预告:浏览器/上下文/页面三层级模型——page 到底从哪来,测试隔离又是怎么做到的。