Playwright 用例时好时坏:retries 是止血不是治本,先分清三类
现象:本地十次全绿,CI 偶尔红一次
流程都差不多。某个结账流程的用例在 CI 上红了,重跑一遍绿了,没人复现得出来。于是有人在 playwright.config.ts 里加一行 retries: 2,红色消失,这件事就结束了。
Playwright 仓库 docs/src/test-retries-js.md 开篇对 test retries 的定义很克制:它是「用例失败时自动重跑」的机制,适用于间歇性失败的用例。文档没有写重试能修好用例,也没有写重试之后这个用例就可信了。所以先把位置摆正——retries 是止血纱布,不是缝合。
真正该做的第一件事是分类。把仓库文档翻一遍,能对上三种性质完全不同的「不稳定」,处置手段各不相同。
第一类:用例之间互相污染。 docs/src/test-retries-js.md 的 Failures 一节描述了 runner 的进程模型:Playwright Test 在 worker 进程里跑用例,这些是独立的操作系统进程,每个 worker 环境相同、各自启动自己的浏览器;一旦有任一用例失败,Playwright Test 会连同浏览器一起丢弃整个 worker 进程,另起一个新的,从下一个用例继续。开启 retries 后,新 worker 会先重试那个失败的用例再往下走。文档同时写明:beforeAll 会在新 worker 里再跑一次。如果你的用例依赖上一个用例留下的状态,这套机制会把问题暴露成随机失败。
第二类:等待条件写错。 docs/src/test-assertions-js.md 把断言明确切成两节,Auto-retrying assertions 会一直重试到通过或到达断言超时,Non-retrying assertions 不会。后一节原文写得直白:网页信息大多是异步呈现的,用不重试的断言容易导致 flaky test,建议优先用自动重试的断言,复杂条件用 expect.poll 或 expect.toPass。这类不稳定跟并发无关,是等待条件本身写漏了。
第三类:它根本不是 flaky,是稳定地坏。 功能没实现,或者在某个 browserName 下必挂。给这类用例配 retries,只是让流水线按 retries 的次数把这个注定失败的用例反复跑一遍,结果仍然是 failed。仓库给这类情况准备的是 test.fixme 与 test.fail,不是 retries。
怎么确认落在哪一类
判定动作有三个,都不用改测试代码。
npx playwright test tests/checkout.spec.ts --repeat-each=10
npx playwright test tests/checkout.spec.ts --repeat-each=10 --workers=1
npx playwright test --last-failed
docs/src/test-cli-js.md 的参数表里,--repeat-each <N> 的说明是「Run each test N times」,配置侧对应 TestConfig.repeatEach,docs/src/test-api/class-testconfig.md 对它的用途说明就是一句「useful for debugging flaky tests」——它本来就是给复现不稳定用的。-j <workers> / --workers <workers> 的说明里写明「use 1 to run in a single worker」。如果加了 --workers=1 就稳定、并发下才抖,指向第一类;单 worker 下重复十次照样抖,那就先去看断言写法。
--last-failed 的说明是「Only re-run the failures」,它读的是上一次运行的记录文件,--last-failed-file <file> 可以覆盖路径,参数表写明默认是 <outputDir>/.last-run.json,也可以用环境变量 PLAYWRIGHT_LAST_RUN_OUTPUT_FILE(这是仓库文档当前记载的默认路径,随版本可能变动)。
以上为按仓库文档中的参数语义组合的示例,未经实测,以仓库最新内容与 --help 的实际输出为准。
还有一个不用改命令的判定点:TestInfo.retry。docs/src/test-api/class-testinfo.md 写明它是整数,首次运行为 0,第一次重试为 1,以此类推,任何用例、hook 或 fixture 里都能读到。docs/src/test-retries-js.md 给的示例是在重试前清掉服务端的某些状态:
import { test, expect } from '@playwright/test';
test('my test', async ({ page }, testInfo) => {
if (testInfo.retry)
await cleanSomeCachesOnTheServer();
// ...
});
这段是仓库文档里的原样示例。它顺带说明了一件事:如果你必须靠「重试前清一遍状态」才能让用例过,那问题本身就是第一类,而不是环境抖动。
仓库文档给出的处置
retries 配在哪一层
三个入口。命令行是 --retries <retries>,参数表的说明是「Maximum retry count for flaky tests, zero for no retries」;配置文件是 TestConfig.retries(自 v1.10 起可用),class-testconfig.md 写明它是失败用例的最大重试次数,并明确「By default failing tests are not retried」——这是仓库当前文档里的默认行为,随版本可能变动。第三个入口是按组配:
// Each test in the file will be retried twice and have a timeout of 20 seconds.
test.describe.configure({ retries: 2, timeout: 20_000 });
这段抄自 docs/src/test-api/class-test.md 的 Test.describe.configure 用法节,2 和 20_000 是仓库示例里的值,不是推荐值。按组配的意义在于:你可以只给那一个确实在抖的文件开重试,而不是给全仓库开。
retryStrategy:重试插在哪儿跑
class-testconfig.md 里有一个容易被忽略的配置项 TestConfig.retryStrategy,自 v1.62 起可用,取值是 'immediate' 与 'isolated' 两个。文档自述:'immediate' 表示一有 worker 空出来就立刻重试,和其余用例交错进行,这是默认值(仓库当前文档里的默认值,随版本可能变动);'isolated' 表示所有其它用例跑完之后,在单个 worker 里逐个重试,文档同时写明这样做「minimizes the interference between retried tests and the rest of the suite, at the expense of the total run time」——代价是总运行时间。
这一条和上面的第一类判定是配套的:怀疑用例互相干扰时,'isolated' 让重试跑在一个干净的位置上。
重试与 trace 的关系,是最容易配错的一处
很多项目的配置里写着 trace: 'on-first-retry',然后在首跑失败时去翻 trace,翻不到。docs/src/test-use-options-js.md 的 Trace modes 一节把这件事列成了表,一共七种模式,区分的是「哪些运行会录」和「录完之后哪些会被保留」:'on-first-retry' 只在第一次重试时录,录了就保留;'retain-on-failure' 每次运行都录、只保留失败的那次;'retain-on-first-failure' 只录首跑;'on-all-retries' 每次重试都录;'retain-on-failure-and-retries' 每次都录,失败的和属于重试的都留。
docs/src/trace-viewer-intro-js.md 自己把这层含义讲得很清楚:脚手架配置里 traces 设为 on-first-retry,意味着 trace 记录在失败用例的第一次重试上,首跑不录、第二次重试也不录。所以如果你的 retries 是 0,'on-first-retry' 等于什么都不录。
这里还有两处文档内部对不上的地方,值得知道但不必据此下结论:docs/src/test-api/class-testoptions.md 里 trace 条目的外层类型联合里没有列 'on-all-retries',而同一条目下 mode 字段的类型联合里列了;docs/src/test-use-options-js.md 的选项说明表只写了四个取值,紧接着的模式表却列了七个。以哪个为准,仓库里没有给出说明,docs/src/test-cli-js.md 中 --trace <mode> 的取值和七种模式那张表是一致的。
serial mode 会把重试成本放大
test.describe.serial 用来把互相依赖的用例绑在一起。docs/src/test-retries-js.md 写明:组里有一个失败,后续用例全部跳过;开了重试则整组一起重试——包括前面已经通过的那些,会再跑一遍。文档在这一节末尾附了一条 note,大意是通常还是把用例做成互相隔离的更好,这样才能独立运行和独立重试。换句话说,用 serial 换来的顺序保证,是拿重试的粒度换的。
第三类:test.fixme 与 test.fail 的分工
这两个都在 docs/src/test-api/class-test.md 里,自 v1.10 起可用,语义完全相反,别用混。
test.fixme 的定义是把用例标成「打算修」,文档写明 Playwright 不会执行 test.fixme() 调用之后的部分。它有几种形态:声明式的 test.fixme(title, body) 与 test.fixme(title, details, body),以及运行时标注的 test.fixme(condition, description)、test.fixme(callback, description) 和无参的 test.fixme()。文档对条件形态的描述是:Playwright 会开始跑这个用例,但在 test.fixme 调用之后立刻中止。文档同时建议条件形态要带上 description,因为它会体现在测试报告里;对无参形态则明确写着更推荐用 test.fixme(title, body)。
test.fail 是另一回事:文档写明它把用例标记为「应当失败」,Playwright 会真的运行这个用例,并确认它确实在失败,用途是把某个功能坏掉这件事记录下来直到修好。形态与 fixme 对称,另有 test.fail.only,自 v1.49 起可用。
import { test, expect } from '@playwright/test';
test.fail('not yet ready', async ({ page }) => {
// ...
});
test('fail in WebKit', async ({ page, browserName }) => {
test.fail(browserName === 'webkit', 'This feature is not implemented for Mac yet');
// ...
});
这两段抄自同一文件的用法节。以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
顺带一提 test.skip 的分工:class-test.md 在 Test.skip 下写着「Skipped tests are not supposed to be ever run. If you intend to fix the test, use test.fixme instead.」——打算修就用 fixme,不打算再跑才用 skip。
别让 flaky 悄悄变成绿色
TestConfig.failOnFlakyTests 自 v1.52 起可用,文档说明是:只要有用例被标为 flaky 就以错误退出,适合 CI。命令行侧同名开关是 --fail-on-flaky-tests。加了 retries 却不加这个,flaky 在流水线上和 passed 长得一模一样,三个月后没人记得它抖过。
处置之后怎么验证
第一,看 runner 的分类。docs/src/test-retries-js.md 写明 Playwright Test 把用例分成三类:首跑就通过的是 passed,首跑失败但重试通过的是 flaky,首跑失败且所有重试都失败的是 failed。你改完之后,那个用例应该从 flaky 变成 passed,而不是仍然停在 flaky 上——停在 flaky 说明重试仍在替你兜底。
第二,如果你自己写 reporter,注意 docs/src/test-reporter-api/class-testcase.md 里 TestCase.outcome 是四个取值 "skipped" | "expected" | "unexpected" | "flaky",并且文件里专门提示 outcome 与 TestResult.status 不是一回事:被标为「应当失败」且确实失败的用例,outcome 记作 'expected';第二次重试才通过的记作 'flaky'。把这两处放在一起看就明白:用 test.fail 标注过的用例不会污染 flaky 统计。
第三,trace 有没有落地。按前面那张模式表倒推一遍你的组合:retries 是几、trace 是哪个模式、你想看的是首跑还是重试。想看首跑失败的现场,'on-first-retry' 帮不上忙。
第四是 Windows 侧。docs/src/test-cli-js.md 的参数表本身不分平台,--retries、--repeat-each 这些在哪个 shell 里都一样写。会有差别的是环境变量:很多项目的配置按 process.env.CI 分支决定 retries(docs/src/trace-viewer-intro-js.md 描述的脚手架配置就是 CI 上重试、本地不重试),要在本机复现 CI 的重试行为就得先把变量设上,而设变量是 shell 层面的写法,不是 Playwright 的功能——仓库 docs/src/debug.md 里给出的 Windows 写法是 cmd 用 set VAR=1、PowerShell 用 $env:VAR=1,Linux 与 macOS 侧是 VAR=1 命令 的前缀形式。
什么情况说明不是这个原因
--repeat-each重复很多次全绿、--workers=1也绿、只有 CI 会红。 这时候不稳定的来源不在用例的并发关系里,加retries或改retryStrategy都不会让它变好。仓库文档没有给出这种情况的诊断结论,我们也不替它推断。- 加了
retries之后每次运行、每次重试都失败。 按仓库的分类口径,它是 failed 而不是 flaky,三类都不是——它是稳定失败。该用test.fail记录,或者干脆去修。 testInfo.retry始终是 0,却看到同一个用例跑了很多遍。 那不是重试机制在起作用。repeatEach的语义是「重复运行每个用例」,仓库文档里我们没有找到它会改变TestInfo.retry取值的说明;CI 层面把整个 job 重跑一遍也不会体现在这个计数上。- serial 组重试时,前面已经通过的用例又跑了一遍。 这是
docs/src/test-retries-js.md写明的行为,不是污染,也不是配置错了。 - 用
test.fixme标过的用例什么断言都没执行。 同样是文档写明的:Playwright 不会执行test.fixme()之后的部分。想让它执行并确认失败,你要的是test.fail。
分类做对了,retries 才有意义:它负责让流水线不因为一次网络抖动全红,剩下的事得靠改用例。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。