不稳定测试:四种成因与各自的证据
用能区分它们的证据,把不稳定测试归因到共享状态、时序、网络或环境漂移,并让重试记录而不是掩盖。
BotBrowser Team
不稳定测试不是「偶尔失败的测试」,而是「失败尚未归因的测试」。重试到通过会产生一次绿色运行,同时销毁能说明四种成因中哪一种在起作用的证据。BotBrowser 文档说明了按启动与按上下文的存储隔离,它消除的是共享状态成因背后的一个机制,而不是这一成因本身。它不修复时序、网络或测试设计成因,也不能单独让测试集变得确定。
摘要
- 在任何重试之前先保存第一次失败尝试。覆盖它的重试已经删除了你唯一的证据。
- 把失败归因到四种成因之一:共享浏览器状态、时序、网络或环境漂移。每种都留下不同的证据。
- 两次失败带有相同特征是可复现的观察。它缩小了成因范围,但并不证明失败不是间歇性的。
- 把重试当作记录装置。记录每次尝试,然后修复成因,并从该测试中移除重试。
- BotBrowser 通过按上下文或按启动隔离存储,消除共享状态的一个机制。它不修复其余三种成因。
目录
四种成因与各自的证据
有用的第一个问题不是「它多久失败一次」,而是「失败发生时看起来像什么」。四种成因各留下一种特征,而每种特征都需要特定证据来确认。一种证据只是提出假设,确认它需要一次对照比较;收集错误的证据会得到一个自信而错误的归因。
| 成因 | 特征 | 确认它的证据 |
|---|---|---|
| 共享状态 | 只有另一个测试先跑过时才失败 | 失败步骤之前的存储、cookie 与缓存转储 |
| 时序 | 加长超时能通过,用正常超时失败 | 带动作耗时与所等事件的 trace |
| 网络 | 在同一个请求上失败,而不是同一个步骤 | 带状态码与重试次数的请求日志 |
| 环境漂移 | 只在某个 runner、配置文件或视口上失败 | 两次运行的启动参数、配置文件哈希与视口 |
共享状态是最容易被浏览器测试集低估的成因,因为证据在失败消息里是看不见的。一个只在另一个测试跑过之后才失败的测试,通常失败在关于内容的断言上,消息指向断言而不是残留存储。测试数据与浏览器状态卫生实践 说明了如何不让这种状态积累。在失败步骤之前立即转储存储与 cookie,才能把它变成一次归因。
时序成因是最常被「修好」却没被诊断的,而等待与稳定测试分析 说明了正确的等待在等什么。一个加长超时就能通过的测试,已经证明等待太短;那是有用证据,不是修复。trace 会显示元素是否晚出现、网络是否慢、动作是否在页面稳定之前触发,这三个答案指向不同的修法。
网络成因与时序成因很容易混淆,因为两者都表现为「元素不在那里」。区别在于:网络成因在同一个请求上复现,时序成因在同一步骤但不同请求上复现。记录请求状态与重试次数,无需猜测就能分开它们。
环境成因看起来像框架 bug。一个在某个 runner 上失败、在另一个上通过的测试,通常是因为两次运行的启动参数、配置文件取值或视口不同。为两次运行记录这三项成本很低,而它正是让比较成为可能的证据。
还有第五种特征值得说明,因为它常被误读为四种之一:测试依赖随时间变化的数据。把价格与字面量比较、假定今天的日期断言、假定固定数量的列表断言,都会按日程失败而不是间歇失败。证据是输入数据及其时间戳,修复属于 fixture 而不是浏览器。把它当作状态或时序成因,会导致有时通过有时失败的重试,这是代价最高的结果。
调查顺序与表格同样重要。先查最便宜排除、最便宜修复的成因,几乎总是状态,因为新上下文要么复现失败要么不复现。然后查环境,因为比较两份已记录的启动配置是一次 diff 而不是一次实验。时序与网络放在最后,因为诊断任何一项都需要 trace 与请求日志,而它们要花真实时间。
还有一条适用于全部四种的卫生要点。证据只有在失败发生那一刻被捕获才有用,事后重建的不算。框架已经导航离开之后拍的截图、清理 hook 跑完之后做的存储转储、只过滤到错误的请求日志,都看起来像证据却不含证据。请在失败 hook 中、任何 teardown 之前捕获,并记录是哪个 hook 产生了证据。
为什么重试会抹掉证据
Playwright 把重试文档化为一种会重跑失败测试的 runner 功能,并把 fixture 与并行执行文档化为各自拥有作用域规则的独立功能。这些文档都没有声称重试能让测试集变可靠。功能只是重跑测试;第一次尝试的证据会发生什么,完全取决于测试集如何配置记录。
默认失败模式很微妙,它体现在一个字段上:status=passed 且 retries=1。Playwright 把最终通过的重试测试报告为 flaky,而 testInfo.status 等于 passed,因此只读最终状态字段的报告看到的是通过,别的一无所有。更严重的问题在于第一次尝试常常根本没有 trace:默认的 trace: 'off' 什么都不记录,on-first-retry 只在重试开始时才开始记录。证据是缺失而不是被覆盖,除非自定义输出路径在多次尝试间复用了同一个文件名。
这就是重试是记录装置而不是修复的原因。用得好,它告诉你哪些测试不稳定,并给出同一次失败的第二次观察。用得不好,它把真实缺陷变成绿色构建,并把发现推到生产环境,那里已经没有证据了。
还有一个统计陷阱。十次运行失败一次的测试,单次重试十次里会有九次通过,因此启用重试的测试集看起来比实际稳定得多。失败率没有变,只是报告变了。连续两次失败带有相同特征是最强的可用信号,但它仍是假设:网络成因可能两次产生相同特征却依然间歇,所以归因需要一组对照。
会记录的重试策略
会记录的策略很小:保留每次尝试、按尝试命名证据、拒绝把第一次失败汇总掉。下面的构造器是一个构造用例,展示记录应当包含什么。
// Constructed fixture: three attempts of one test, two failures with a matching artifact prefix.
const attempts = [
{ n: 1, status: 'failed', artifact: 'storage-dump:t1.json', at: '2026-11-10T09:00:12Z' },
{ n: 2, status: 'failed', artifact: 'storage-dump:t2.json', at: '2026-11-10T09:00:48Z' },
{ n: 3, status: 'passed', artifact: 'trace:t3.zip', at: '2026-11-10T09:01:30Z' },
];
const failures = attempts.filter(a => a.status === 'failed');
const sameArtifact = failures.length > 1 && failures[0].artifact.split(':')[0] === failures[1].artifact.split(':')[0];
const verdict = sameArtifact ? 'repeated_signature' : failures.length ? 'single_failure' : 'clean';
const lines = [
`attempts=${attempts.length} failures=${failures.length}`,
...attempts.map(a => `attempt=${a.n} status=${a.status} artifact=${a.artifact} at=${a.at}`),
`verdict=${verdict}`,
];
console.log(lines.join('\n'));
下面的构造输出正是重点:第三次尝试通过了,记录仍然说明特征重复出现。
attempts=3 failures=2
attempt=1 status=failed artifact=storage-dump:t1.json at=2026-11-10T09:00:12Z
attempt=2 status=failed artifact=storage-dump:t2.json at=2026-11-10T09:00:48Z
attempt=3 status=passed artifact=trace:t3.zip at=2026-11-10T09:01:30Z
verdict=repeated_signature
三条规则让策略在实践中可用。按成因而不是按文件命名证据,让 storage-dump 与 trace 一眼可辨。按顺序保留尝试及其时间戳,让复核者看到失败是立即的还是延迟的。把结论做成派生字段而不是人的判断,报告就无法悄悄把重复失败叫作不稳定。
还有两条规则属于报告而不属于构造器。第一,把失败数与通过数并列发布,因为只报告最终状态的测试集会隐藏被重试抹掉的失败。第二,保留每次尝试的证据而不只是最后一次,因为第一次尝试发生在任何重试专属的清理之前,而在重试 hook 里清状态的做法已经改变了产生这次失败的条件。
重试范围是第三个考虑。测试级重试给出一个测试的重复观察;文件级重试只重跑失败的测试,而为串行组配置的重试会重跑整个组,包括没有失败的测试,这会改变哪些测试共享状态,并可能让第二次尝试变成另一个实验。Playwright 也记录了按组或按串行组重试的方式,此时组内所有测试一起重试,因此第二次尝试执行的是另一组测试。请选择能复现失败的最窄范围,并把范围记进尝试记录,让后来的读者知道实际重跑了什么。
重试策略还需要负责人与退出条件。负责人是能修复成因的人,退出条件是一个日期,到那时重试必须移除或测试必须隔离。缺少两者,事故期间加的重试会变成没人再看的永久基础设施,测试集慢慢被真实状态不明的测试填满。
隔离是「永远重试」的诚实替代。把已知损坏的测试移出必需集合,附上证据并指名负责人,能在调查成因的同时保持测试集的信号完整。它是可见的失败而不是隐藏的失败,而可见的失败会被修复。
策略还需要一条停止规则。如果两次尝试带相同特征失败,停止重试并改一个变量:特征已可复现,所以同一配置下的第三次尝试不会带来新信息。下一步是表格里的对照比较,而不是第三次重试。此后继续重试只是在花时间产出已经存在的证据,并把测试集推向「靠重试变绿」的状态,而策略存在的意义正是阻止这种状态。
状态隔离清单
共享状态值得先查,因为它最容易消除、也最容易被误当成别的成因。下面每一项都产出一个观察而不是一个信念,因此这份清单可以在诊断其余三种成因之前跑完。
| 检查 | 它产生的观察 |
|---|---|
| 每个测试使用新上下文 | 失败在空上下文里是否仍然存在 |
| 导航之前清理存储 | 残留的本地或会话存储是否重要 |
| 清理 cookie,而不只是让它过期 | 持久 cookie 是否改变了结果 |
| worker 之间不共享配置文件 | 两个 worker 是否写入了同一个配置文件 |
| 固定视口与设备取值 | 布局差异是否改变了结果 |
| 记录每次运行的启动参数 | 两次运行是否配置一致 |
fixture 与隔离模型 描述了让「每个测试新上下文」可行的作用域设计。第一行是决定性的。如果测试在共享上下文中失败、在新上下文中通过,成因就是状态,任何超时调整都不会解决它。如果两者都失败,状态成因不太可能,接下来两种值得投入。
第四行在并行执行下变得紧迫。Playwright 把并行执行文档化为在独立 worker 中运行测试,而一个在测试之间复用同一配置文件的 worker,会把状态留给该 worker 的下一个测试,同时让其他 worker 保持干净。结果是失败模式看起来按 worker 数量随机,而不是按测试随机。
worker 复用会产生第二个更微妙的信号:失败会移动。如果同一测试在串行运行中通过、只在并行时失败,而且失败的断言在不同运行之间变化,成因几乎肯定是跨 worker 边界的共享状态。把 worker 标识与每次尝试一起记录,能把这种怀疑变成模式,因为失败的尝试会聚集在一个 worker 上而不是均匀分布。
清理存储不等于清理 cookie,两者也都不等于启动新上下文。本地存储、会话存储、IndexedDB、service worker 缓存与 HTTP 缓存各自在不同重置后存活,依赖其中之一的测试在清掉其他项后仍会失败。最便宜的完整重置是每个测试一个新浏览器上下文,这也正是清单第一行产出的观察。
service worker 值得单独一行,因为它在页面重载后仍存活,并可能在测试以为已清理存储很久之后仍返回陈旧响应。一个在部署后第一次运行失败、之后通过的测试,常常是在读由先前测试注册的 worker 的缓存响应。记录失败时是否注册了 service worker,是把这种情况与网络成因分开的证据。
清单最后一项让其余五项可复现。两次运行使用不同启动参数就不是同一个实验,只出现在其中一次的失败可能是配置差异而不是代码差异。把参数、配置文件哈希与视口一起记录,给复核者一个可比较的字符串,而比较两个这样的字符串是一次 diff 而不是一场争论。
BotBrowser 的配置文件管理文档指出,每次启动使用自己的 user-data-dir、cookie 与会话设置,而在同一进程内,存储、cookie 与上下文设置由 BrowserContext 隔离。它的配置文件文档列出了配置文件所携带的环境输入,例如区域设置、时区、屏幕与 WebGL。两者合起来消除的是测试之间状态的一个机制:新上下文或新启动不会继承上一个测试的存储。这比「消除状态成因」本身要窄,也不涉及时序、网络或测试设计,因此有时序问题的测试集即使配置文件完美仍然不稳定。
常见问题
重试后通过的测试是不稳定测试吗?
它是一个失败尚未归因的测试。重试改变了结果,没有改变成因。请保留第一次尝试的证据,并在判定测试健康之前完成归因。
加长超时能消除不稳定吗?
只有当成因是时序,而且 trace 已经表明等待是限制因素时才可以。为状态或网络成因加长超时,只会把失败隐藏得更久,不会改变它。
为什么测试只在某个 runner 上失败?
这个特征指向环境漂移:不同的启动参数、配置文件或视口。请为两次运行记录这三项并在寻找框架 bug 之前先比较它们。
BotBrowser 能让不稳定的测试集变确定吗?
不能。它文档说明了按启动与按上下文的存储隔离,消除的是共享状态成因背后的一个机制。时序、网络与测试设计成因依然存在,没有任何浏览器配置能单独让测试集变确定。
来源
- Playwright 测试重试
- Playwright 测试 fixture
- Playwright 并行执行
- BotBrowser 配置文件管理
- BotBrowser 配置文件
- BotBrowser 自动化一致性
实践结论
在任何重试之前保存第一次失败尝试,用能区分它们的证据把失败归因到状态、时序、网络或环境,然后只改一处。把重试当作记录装置:每次尝试带证据与时间戳、结论做成派生字段、两次以相同方式失败就停止。先测共享状态那个机制,因为它最便宜可测,并记住按上下文的存储隔离只处理这一个机制。