技术10/10/2026·约 4 分钟

那个换了默认值、却没有通知任何人的依赖

上游一次发布用一行替换了默认的 iOS 测试 runner,正文是空的。我们 1,070 个测试目标在一夜之间离开了模拟器产能租约,而症状不是报错。

一行,没有正文

我们升级了一个构建规则库。升级就是升级。

这个库提供一个 default_runner label——那个决定 iOS 测试究竟怎么执行的 label。我们这次升级里包含这个改动:

5.0.0 把默认 iOS 测试 runner 换成了由 xcodebuild test-without-building 驱动的 runner。default_runner/BUILD 里的一行。正文为空。合并于 2026-06-10,一次大版本操作系统发布的次日。

那个仓库里相邻的提交把方向说得很清楚:一个在收敛各平台测试 runner,一个把 simulator creator 暴露成可替换 action,而新 runner 自己在最新的工具链上还挂。但方向不等于破坏性变更通知,而那个本可以是通知的提交什么都没有。

我们四个月后才知道,而且不是从升级里知道的。

「不读我们的环境变量」要付多少

跑一个 iOS 测试是一个共享资源问题。模拟器不是纯函数——它是一台有名字、有生命周期、有租约的机器。我们对此的答案是一层小模拟器管理器,它拥有三件事:设备怎么命名、创建与删除如何串行、设备如何回收。测试从它那里取一台具名模拟器,而不是让 runner 自己随便造一台。

新 runner 带着它自己的 simulator creator,而那个 creator 不读我们用来命名和预订设备的环境变量。于是:

  • 设备以 BAZEL_TEST_<device>_<os> 起来,而不是我们产能层能追踪的名字;
  • 创建与删除之间不再互相串行;
  • reuse 默认打开,于是设备活得比创建它的那个作业更久。

我们 1,070 个单测目标在一夜之间离开了产能租约。

而这一点才是最值得精确说明的部分:什么都没失败。

症状不是报错

一个测试套件在模拟器上和另一个套件相撞,有两种可能结果。坏的那种是工具察觉到并报一次失败。我们得到的是另一种:两个套件都跑了,在一台别人也在驾驶的设备上,然后都通过了。

我们手上的每一个绿灯仍然是绿的。构建是对的。断言是对的。变的是一千个 target 在共享一小撮机器,且构建图里没有任何东西知道这件事。

这正是版本 pin 挡不住的那种依赖风险。版本 pin 保证的是「这份代码」。它不保证那份代码解析到哪个默认,而默认是上游替你做的一个决定,写在一个你不读的文件里,描述着你从未调用过的行为。

这个失效是后来才显形的,而且是间接的——当另一次 runner 切换让所有产品的付费墙 UI 测试整个变红,调查才把线索引回四个月前的那次交换。

修法是单一 owner,而不是一项配置

诱人的修法是去设新 runner 想要的环境变量,或者去 patch 它的 simulator creator。那两种做法都会让下一次上游改动变成一个全新的事故。

真正修好它的是:仓库里每一个 iOS 测试 target 本来就已经经由我们自己的宏抵达 runner——单测一个宏、UI 测试一个宏,两者最终都解析到我们控制的同一个 runner target。这不是一条约定,而是被逼出来的:上游那个默认被钉死在它自己仓库内部的 label 上,下游代码指不到别处,所以整个仓库本来就只能走我们自己的入口。

于是一次交换无法悄悄生效;当我们把旧 runner 钉回去时,一行就换掉了我们全部测试的执行环境。

这条教训越出构建系统。只要某个默认值可能被别人的发布改掉,持久的保护就不是「保持警惕」或者「钉版本」。真正的保护是:让你自己的代码没有任何路径能直接抵达那个默认——于是改它是你仓库里一个刻意的动作,而不是一个继承来的属性。

那次我们做不完的迁移

钉回去修好了产能问题,而上游迁移还悬着,于是我们试着把它做完。没成。

新 runner 通过 xcodebuild test-without-building 驱动测试,这与我们付费墙测试依赖的本地 StoreKit 配置不兼容。每一条付费墙 UI 测试全红:

配置结果
旧工具链 + 旧 runner3/3 通过
升级后 + 旧 runner3/3 通过
升级后 + 新 runner3/3 失败

所有十一个走这条路径测付费墙的产品同时受影响。于是我们钉了回去——这是诚实的结果——并把钉回去的代价写下来:给旧 runner 的四个补丁,靠它们维持它的设备命名、生命周期串行与启动行为。这些补丁现在归我们维护。

我们还把我们不知道的东西写了下去。付费墙那一簇之外,还有某一个产品里大约二十条 editor/home/overlay 的失败、四个键盘扩展 target 报「系统键盘里没有我们的扩展」、一条账号流失败和一条 smoke 失败。它们是否同源从未做过对照,而那份指南明确这么写,而不是把它们归到只在另一批失败上被证明过的那个原因下。

最后这一点是我们最想坚持的。回滚说明里最诱人的做法,是把所有当前变红的东西都解释一遍。忍住只花一段话,却保住了整份文档其余部分的可信度。

小结

审计一个依赖的默认行为做了什么,而不仅仅是它的版本 pin 锁住了什么。pin 固定的是你拿到的代码。它不固定那份代码会解析到哪个分支的行为,而默认是别人在你的系统上替你做的决定,写在你没读的文件里,发生在一个你不在盯着日子里。

把「一切都是绿的」当成一句关于环境的断言,而不只是关于代码。我们的测试在一千个目标共享着它们没有租约的机器时,依然全绿。绿意味着断言成立了;它从来没有意味着这次运行就是你以为的那一次。如果正确性依赖于某项资源独属于你,那么这个独占必须在某处被断言,而不是由你继承来的那个 harness 默默假设。

让每一个消费者都经过一个你能控制的 owner,于是默认值无法在你背后改变。这里最强的保护既不是版本 pin 也不是配置项,而是我们自己的代码没有任何路径能直接抵达上游那个默认。正是这一点把一场沉默四个月的回归,变成了一行的改动。

而当你把某样东西钉回去时,把你放弃的和从未解释的都写下来。一份把当前所有红色都解释掉的回滚说明不是周全;那是一个穿着文档外衣的猜测。点名那些你还没有诊断的失败,正是让你确实诊断过的那些值得被读的原因。

继续阅读