Test timeout of 30000ms exceeded. 的意思是:某个 Playwright 测试用完了它的 30 秒时限。30 秒是 Playwright Test 的默认值,这段时间要同时覆盖测试主体、beforeEach 钩子和 fixture 的准备。报错点名的是测试而不是某一步,是因为点击、输入和页面跳转默认都没有自己的超时。它们会一直等下去,测试超时只是最先响的那个闹钟。
所以这个错误很少真的是「太慢」。多半是某一步在等一件永远不会发生的事。按我排查的顺序,原因和对应的修法如下:
- 定位器(locator)什么都没匹配到,于是
click()或fill()一直等到测试时间用完。常见原因是测试 ID 改了、文字改了,或者有个对话框挡在前面。改定位器。 - 页面跳转或
waitForURL()在等一个永远不会出现的页面,因为重定向、登录页或首次启动页把应用带去了别处。改你等待的条件。 - 准备工作确实慢:第一次请求才开始编译的开发服务器、从缩容到零状态被唤醒的后端、每个
beforeEach里都走一遍 UI 登录。改环境,或者只准备一次。 - 测试本来就长。这时,也只有这时,才用
test.slow()或test.setTimeout()给这个测试加时间。
不管是哪种,第一件事都是找到时间用完那一刻还在执行的那一步。第二件事是设置 actionTimeout,让下一次失败直接告诉你是哪一步。
这 30 秒里都有什么#
测试超时是一份时限,由 fixture 准备、beforeEach 钩子和测试函数共用。测试结束后,afterEach 钩子和 fixture 清理(teardown)另外再拿一份同样长的时限。beforeAll 和 afterAll 也拿同样的值,但不和任何测试共用,失败时也有自己的报错。
在这份时限里,只有断言有自己的计时。await expect(locator).toBeVisible() 默认 5 秒后放弃,并准确告诉你它在找什么。动作和页面跳转则不然:actionTimeout 和 navigationTimeout 默认不设上限。这个错误之所以这么含糊,原因全在这里。去点一个永远不会出现的元素,并不会失败,它只会老老实实地等,直到别的东西把测试结束掉。
下面是所有能让一次运行结束的超时,以及各自打印的内容。这些报错原文取自 Playwright 1.58 和 1.63,也就是我项目里最旧和最新的两个版本。
| 超时 | 默认值 | 在哪里设置 | 打印的内容 |
|---|---|---|---|
测试(主体、beforeEach、fixture 准备) | 30,000 ms | 配置里的 timeout、--timeout、test.setTimeout()、test.slow() | Test timeout of 30000ms exceeded. |
beforeAll / afterAll | 与测试超时相同 | 在钩子里调用 test.setTimeout() | "beforeAll" hook timeout of 30000ms exceeded. |
断言(expect) | 5,000 ms | 配置里的 expect: { timeout },或单个断言上的 { timeout } | expect(locator).toBeVisible() failed … Timeout: 5000ms |
动作(click、fill) | 无 | use: { actionTimeout },或单个动作上的 { timeout } | TimeoutError: locator.click: Timeout 10000ms exceeded. |
页面跳转(goto、waitForURL) | 无 | use: { navigationTimeout },或单次调用上的 { timeout } | TimeoutError: page.waitForURL: Timeout 15000ms exceeded. |
| 整次运行 | 无 | 配置里的 globalTimeout | Timed out waiting 3600s for the test suite to run |
webServer 启动 | 60,000 ms | webServer: { timeout } | Timed out waiting 60000ms from config.webServer. |
动作、页面跳转和整次运行这三行没有默认值可打印,所以里面的数字就是你自己设的值。
五个测试和一个改了名的测试 ID#
今年 2 月,我做的估价应用的测试套件在一次运行里有五个测试失败,而且失败方式一模一样。这套测试用 Playwright 驱动应用的 Web 版(由开发服务器提供),只开一个 worker,连的是刚灌好种子数据的本地后端。下面是五个里的第一个,稍作删减:
1) [default] › tests/templates.spec.ts:50:7 › Templates › should edit a template ───
Test timeout of 30000ms exceeded.
Error: locator.click: Target page, context or browser has been closed
Call log:
- waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')
57 | .locator('[data-testid="template-item"]')
58 | .filter({ hasText: "E2E Edit Template" });
> 59 | await item.locator('[data-testid="edit-template"]').click();
| ^
60 | await page.waitForTimeout(500);单看 Target page, context or browser has been closed 这一行,像是浏览器崩了。其实不是。这是页面在脚下被关掉时,动作给出的报错:30 秒到了,Playwright 开始收拾这个测试,那个还在等待的点击就报告说它的页面没了。如果用的是内置的 page fixture,这一行会再说一遍超时,写成 Error: locator.click: Test timeout of 30000ms exceeded.。这些测试是在 beforeAll 里自己开的浏览器上下文,所以点击拿到的是通用版本的报错。
真正有用的是调用日志(call log)。点击在等 [data-testid="template-item"]。十个小时前,我把列表行组件的测试 ID 从 template-item 改成了 template-item-${name},好让原生应用的端到端测试能按名字挑出某一行。用 = 的属性选择器要求整个值完全相等,于是 Web 测试要找的元素在页面上已经不存在了;又没有动作超时,这个点击注定要一直等到测试超时。五个失败各 30 秒,就是两分半钟,而整次运行一共才 3.8 分钟。
我第一时间加的是重试。不到半小时,配置里就多了 retries: process.env.CI ? 2 : 1,旁边的注释至今还写着 “for flaky browser crashes”,意思是用来应付偶发的浏览器崩溃。下一次运行,同样那五个里有四个连同重试一起又挂了,耗时从 3.8 分钟涨到 6.4 分钟。这几个 describe 块跑在串行(serial)模式下:一个失败,组里剩下的全部跳过;重试则从组里第一个测试重新开始。所以每个坏掉的点击都把 30 秒等了两遍。每次都以同样方式失败的测试,重试救不回来,只会让你付两遍代价。
修法是改成前缀匹配:
const item = page
.locator('[data-testid^="template-item-"]')
.filter({ hasText: "E2E Edit Template" });
await item.locator('[data-testid="edit-template"]').click();往大里说,测试 ID 也是一种契约。我有两套测试都依赖这一个 ID,而彼此都不知道对方的存在。Playwright 的最佳实践指南建议优先用 getByRole() 这类基于用户可见属性的定位器,而不是我这种 CSS 选择器,为的就是让测试扛得住那些用户根本察觉不到的 DOM 改动。
找到卡住的那一步#
先看调用日志。它属于时间用完时还在执行的那一步;如果 waiting for locator(...) 后面什么都没有,说明定位器从头到尾都没匹配上任何东西。
还不够的话,就打开 trace。我的两个应用测试套件,一个是估价应用的,一个是我做的一个集市兼社区应用的,用的都是 trace: "on-first-retry"。它只在第一次重试时录制,所以 retries 至少要设成 1。不开重试的话用 "retain-on-failure";临时跑一次就加 --trace on。然后直接打开 trace,或者从 HTML 报告里打开:
npx playwright show-trace test-results/<test-folder>/trace.zip
npx playwright show-report没执行完的那个动作,就是列表末尾附近带着报错的那一项。选中它看快照:那就是 Playwright 等待时页面的样子,你可以在里面找你期待的那个元素。换成我这个例子,那一行就好好地在那儿,只是带着一个测试没在找的 ID。
较新的版本还会在失败截图旁边写一个 error-context.md,里面是失败那一刻页面的文本快照。元素找不到的情况,往往光看它就够了。
有个坑:--debug 打开 Inspector 的同时,还会设置 --timeout=0,外加 --max-failures=1、--headed 和 --workers=1。在 --debug 下能通过的测试,对这 30 秒什么也说明不了。
给每个动作设个上限#
我的配置文件里没有一个设了动作超时,所以一个找不到的元素才能五次各耗掉 30 秒。在 use 里加两行就能改过来:
import { defineConfig } from "@playwright/test";
export default defineConfig({
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
actionTimeout: 10_000,
navigationTimeout: 15_000,
trace: "retain-on-failure",
},
});同样那个坏掉的定位器,就会在测试还剩时间的时候这样失败:
TimeoutError: locator.click: Timeout 10000ms exceeded.
Call log:
- waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')哪一步、哪个动作、等了多久,全写在上面。我觉得这两个值都应该明显小于测试超时,这样某一步卡住时,不会把整份时限耗光,再躲到那条含糊的报错后面。2 月那次,问题从来不在 30 秒。那个点击身上只有这一个计时器,所以响的就是它。
只给长测试加时间,别给卡住的测试加#
测试确实在做耗时的工作时,加超时是对的;某一步在等一件不会发生的事时,加超时就是错的,数字再大也只是让失败来得更晚。到底是哪种,通常看一眼 trace 就清楚了:动作一个接一个都完成了,只是数量多,那是长测试;有一个动作直到最后都停在 waiting for,那是卡住的测试。
对长测试,时间要加在真正长的地方,而不是整个套件。集市应用的套件里有一个冒烟测试,会一边截图一边把聊天界面走一遍,我给了它三分钟:
test("chat: open, send a message, go back", async ({ page }) => {
test.setTimeout(180_000);
// ...
});在 describe 里调用 test.setTimeout(),会作用于组内所有测试,估价应用套件里的成本计算(job-cost)测试就是这样拿到一分钟的。test.slow() 会把当前的超时乘以三,不管它原来是多少,也可以带条件。在 beforeEach 里用 testInfo.setTimeout(),会延长所有经过这个钩子的测试:
test("slow in WebKit", async ({ page, browserName }) => {
test.slow(browserName === "webkit", "This page is slow in WebKit");
// ...
});
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(testInfo.timeout + 30_000);
});还有一样东西每次都在悄悄吃掉时限:固定等待。估价应用的套件里,几乎每个 spec 文件都用到了 page.waitForTimeout(),加起来一百多处。上面那个编辑测试在走到点击之前先睡了 3 秒,可睡多久都救不了它。Playwright 的 API 文档在这一点上说得很直白:“Never wait for timeout in production. Tests that wait for time are inherently flaky.” 也就是说,正式环境里永远不要按时间等待,靠时间等待的测试天生就不稳定。Web 优先断言(web-first assertion)本来就会等到需要的时候为止,一点不多等:
// before
await page.locator('[data-testid="save-template"]').click();
await page.waitForTimeout(1500);
await expect(page.getByText("E2E Edited Template")).toBeVisible();
// after
await page.locator('[data-testid="save-template"]').click();
await expect(page.getByText("E2E Edited Template")).toBeVisible();断言超时也要同样小心。两个应用套件都把 expect.timeout 调到了 15 秒,而且都是对着应用的开发服务器跑,而不是生产构建。这样是多了余量,但连着两个慢断言就可能把 30 秒用光,于是测试超时先触发,你又回到了那条含糊的报错。
当计时的不是测试本身#
如果报错里出现 config.webServer,说明还一个测试都没跑。Playwright 会先启动你的 webServer 命令,等它的 url 有响应了才开始跑测试。这段等待有单独的超时,默认 60 秒。两个应用套件都给开发服务器留了两分钟:
webServer: {
command: "npm run web -- --port 8081",
url: "http://localhost:8081",
reuseExistingServer: false,
timeout: 120_000,
},如果测试对着的是部署好的环境而不是本地服务器,先搞清楚它的第一个请求要多久。我终于在自己用的托管平台上量了一次,一台从缩容到零状态启动的机器,要 25 到 33 秒才能响应任何请求,还没点第一下,测试的时限就用掉了大半甚至全部。那段经历写在缩容到零,于是每次重启都是一次 30 秒的故障里。
如果报的是「No tests found」#
No tests found 听起来相关,但它不是超时:Playwright 找了一圈,没有可跑的东西。默认情况下,它会在 testDir 下收集匹配 **/*.@(spec|test).?(c|m)[jt]s?(x) 的文件,而 testDir 默认就是配置文件所在的目录。我的配置都指向配置文件旁边的 testDir: "./tests",所以放在别处的 spec 根本看不见。命令行参数是拿来匹配文件路径的正则表达式,所以报错里才会附上一句 “Make sure that arguments are regular expressions matching test files."。想在不运行任何东西的情况下,看看 Playwright 收集到了什么:
npx playwright test --list如果你的 spec 不在这个列表里,它压根就没机会超时。另外,如果你是第一次搭 Playwright,我在小团队更该做视觉测试里讲过为什么建议从截图对比开始。
