技术8/26/2026·约 5 分钟

谁跑的测试,谁说了算

CI 编排器靠刮 stdout 重新推导成败,于是重试后通过的测试被判红。判决现在来自 Bazel 的事件流,下游任何一环都不得重算。

那个被报成红的绿

我们的 CI 是一个编排器,负责决定构建什么、跑什么、以及结果是否可以接受。很长一段时间,它是通过读构建日志来决定最后这一件事的。

这在还没有 flaky 测试的时候是够用的。Bazel 有重试机制:一个失败的 target 可以重跑,重跑过了,Bazel 就报绿。从 Bazel 的角度这是正确的、有文档的行为——重试正是它区分「间歇性失败」和「真失败」的方式,而且它自己就有一套记账来说明这件事发生了多频繁。而从我们这边看,那个 target 的日志里同时有失败和通过。

于是编排器读日志、看见了失败、报红。不是因为测试失败了——而是因为它失败过一次,然后没有。每个 flake 都无法作为 flake 被上报,只能作为失败被上报,也就是说,唯一能告诉我们真实不稳定性在哪里的那个信号,在抵达的那一刻就被销毁了。与此同时,偶发的「真的坏了但重试过了」和同一个事件无法区分,于是重试规则是靠感觉调出来的。

同一个 bug 还有一个更安静的版本。一条没能展开的查询被报成「这次改动没有影响任何 target」——一次基础设施失败伪装成一张健康证明。说「没事要做」的流水线和说「我没查出来」的流水线是同样两个词,而只有其中一个可以据此行动。

两个问题穿同一件外套

根因是两个不相关的问题被写进了同一个地方:

  1. 测试运行器决定了什么?
  2. CI 要拿它怎么办?

这不是同一个问题。第二个是策略选择——我们在什么时候决定容忍一次 flake——只有策略的 owner 才能回答。第一个是观测,而从运行器那份人类可读的输出里重新推导它,是一个伪装成事实的猜测。输出不是契约,它是散文,谁改一下排版它就变了。

修法把观测挪到了一个真正在进行观测的地方。Bazel 会为每个构建事件发出机器可读的协议,编排器现在读那条流:哪些 target 被构建了、每个最终状态是什么、拿到那个状态之前失败了几次。下游没有任何一环重算 pass/fail。两个模块是刻意分开的,策略模块顶上那行注释一句话讲清了原因:过去每一次假红,都来自某个地方一边想表达「CI 想要什么」、一边却重新推导了「Bazel 决定了什么」。

这次改动是 28 个文件、加了约 1700 行,大部分在事件解析和判决模块里,因为刮日志的逻辑此前散落在编排器各处,拆掉它意味着要把它从周围长出来的另外四个阶段里解出来。

证据的缺失不是一次失败

这次重构产出一个值得单独拎出来的决定,因为它是最多 CI 系统会做错的那一个。

如果构建压根没有产出事件流——连接断了、产物缺失、工具在发出事件之前就停了——判决是未知,不是零失败。流水线返回的判决对象报告零通过、零失败、零跳过,然后让退出码和重试分类器决定接下来怎么办。凭缺失的证据造出一个测试失败,会产出一个红的构建,而它指向的是代码而不是基础设施——这正好是反的:它把一个人送去读一份和实际故障毫无关系的 diff。

同样的逻辑下沉一层是成立的。遍历 target 时有三种情况被分开计数,而其中只有一种算失败:

  • 最终为绿 —— 通过
  • 最终是一个真正的测试判决(断言失败、target 超时)—— 失败
  • 两者都不是 —— 跳过

一个从没构建出来、或者它的工具中途停掉的 target,没有产出判决。把它算成失败是同一个错误的小号版本,也正是让「0 个测试失败、47 个 target 下落不明」被报成一次干净运行的那个错误。

「重试后通过」是策略,不是事实

观测干净之后,剩下的问题是:一个重试之后才通过的 target 我们该怎么办。这是策略,于是它现在住在一个地方——一个常量,旁边就放着解释它的那句话:

/// Whether a target Bazel retried into a pass is accepted as a pass.
const ACCEPT_RETRIED_PASS: bool = false;

false 是我们本来就做的选择:重试后通过判红,于是 flake 保持可见并被修掉,而不是被悄悄吸收进基线。它为每个 flake 多付一次完整运行却买不到任何绿灯——一笔真实的交易,也是那种通常会被「最后在调的那个人当下的心情」重新翻案的交易。变的是它现在只是一笔交易。它是一个常量,在一个文件里,理由就写在旁边。把它翻过来就是「允许声明为 flaky 的 target 豁免」的全部内容;判决路径上再没有别的地方对 flaky 持有意见。

真正存在例外的情形,用一条具名条目而不是一次策略变更来处理。某个 flake 无法从本仓库修掉的 target——一套 iPad UI 测试,它的间歇失败来自系统自己在多任务转场里的一条断言,在被测 app 启动之前就打死了 shell——是豁免的,因为对它来说「让 flake 保持可见」这项策略什么也买不到:每个碰到该 target 的合并请求都要多付一次完整运行,然后照样被拒。每一条豁免都带着「成因在外部」的证据和删除它的条件,而且匹配走的是精确 target 标签。这里若用前缀或子串匹配,会静默覆盖掉一堆没人授予豁免的 target。

于是这项策略变得可 grep、可评审、可删除。上游根因修好之后,移除豁免就是删掉一行——而不是要先做一轮考古,判断这套测试是不是又稳定了。

另一半:九个 TestMain

同一个不变式还有第二个 owner,在 Go 这一侧,而它长出了九次。

Go 的 TestMain 在测试二进制的测试之前和之后各跑一次。我们的 TestMain 被用来清理一次性 fixture——一个 Postgres 容器、一条队列、一个临时目录。清理发生在测试之后,而如果 fixture 拒绝停止,最自然的写法就是在清理内部让测试二进制失败,调 os.Exit(1) 或者赋一个非零退出码。那会覆盖掉测试二进制已经决定的判决。全部测试都通过的一次运行,因为容器没能及时停下而报失败——更糟的是在更安静的方向上,一次真的有失败的运行,可能被一个以另一种方式失败的 fixture 覆盖掉。

现在这条规则有唯一的 owner,而这个区分和 Rust 侧形状相同:丢弃 fixture 是尽力而为、上报失败,但不改变判决。为进程退出码做的清理,和为单条测试断言做的清理,是两种不同的操作、两个不同的 owner,于是拥有 fixture 拆除的那个包同时提供两者,而不是让每个测试文件各自重造一遍。

这个区分是被强制的,因为九份拷贝不是意外,而是一种复制粘贴文化:

id = "test-verdict-ownership"
risk = "high"
paths = ["Backends/**/*_test.go", "Analytics/**/*_test.go", "Foundation/Backend/**/*_test.go"]
forbid = ["*code = 1", "*os.exit(code)*"]

第一个 glob 刻意做了尾部锚定,好让一个更长的字面量——某个合法地增长到以那些字符结尾的计数器——不会误触发。这几条规则是对着那三棵树下每一个 Go 文件实测出来的:合起来只选中那九份已知拷贝,别的一个都不选。同一个不变式的 CI 那一半是单独一份清单,因为一张扁平的 forbid 列表作用于所有被列出的路径时,会拿编排器那套词汇去扫每一个 Go 测试文件,而那套词汇里有些就是普通英语。

小结

判决属于跑测试的人。任何从运行器输出里重新推导 pass/fail 的阶段都是在猜——输出是散文,散文会随着谁改排版而变。去读运行器的机器可读事件协议,并把「被上报的」和「被推导的」当作一道值得在代码里点名的边界。

把观测和策略分开。大多数 CI 的 bug 就住在「发生了什么」和「我们拿它怎么办」共用一个函数的那条缝上。拆成两个模块、各写一句说明自己是哪个,成本几乎为零,而下一个 bug 会立刻变得明显。

区分三种状态,而不是两种。缺席、通过、失败是三个不同的事实,把缺席并进其中任何一个都会产出一个自信的错答案。证据缺失是一种基础设施状态;它意味着什么,交给重试机制和退出码去决定。

并且把容忍写成一个常量,而不是一种心情。吸收 flake 的冲动很强,而且多数时候是错的;纠偏的办法不是禁掉它,而是把它连同成本一起写在一处,并要求每一条例外都指明自己的成因和自己的失效日期。

文中提到的产品

继续阅读