12  Tests:绿灯可不可信

测试不关心 Agent 写得像不像标准答案,只关心系统在真实输入下做了什么。 前提是 —— 这个”绿”本身得是真的。

整章都在讲这个前提。因为在 Agent 大量产出的场景下,假绿不是一个边缘情况, 它是主流失败模式,而理由是结构性的:

让一个断言通过,永远比让一个行为正确要容易。

一个被要求”加测试”的 Agent,面对的是一个优化问题:怎样最快地让检查变绿。 “写一个不会失败的测试”是这个优化问题的一个合法解 —— 它满足了所有可见的约束,只是没有满足那个没被写下来的约束。

12.1 金字塔的真实形状

这个形状不是照着书画的,是被成本逼出来的。取完整的一天, CI 上跑了 2,382 个任务,各层的中位耗时是:构建 1.9 分钟、 结构检查 5.2 分钟、单元与集成 7.8 分钟、界面与端到端 14.9 分钟。

越往上一层,一次判定越贵 —— 界面层的单次成本接近结构检查的三倍, 而它能覆盖的路径反而最少。金字塔的形状,说到底就是 “把判定尽量下沉到便宜的那一层”。

但成本不是唯一理由,甚至不是主要理由。小节 17.3 会说明: 真正的理由是稳定性 —— 这是一个串级控制结构,快内环先抑制大部分扰动, 慢外环只处理漏过来的。串级有一个定量前提:如果内环不够快, 整个结构就白搭。

12.2 什么才算通过

12.2.1 变异验证

最重要的一条:把那个修复拿掉,回归测试必须重新变红。 如果它不变红,说明这条测试守的根本不是行为,只是”这段代码还在”。

这一条是这一章所有内容的根,而且今天就能开始做。它把”测试通过了吗”这个问题, 换成了一个可以真正回答的问题:这条测试有能力失败吗? 这两个问题的差距,就是假绿的全部藏身之处。

flowchart LR
    F["修复"] --> T1["跑测试"] --> G1{{"绿"}}
    G1 --> R["拿掉修复"]
    R --> T2["再跑一次"]
    T2 --> Q{"变红了吗?"}
    Q -->|"红了"| OK["这条测试守的是行为"]
    Q -->|"还是绿"| BAD["它守的只是<br/>「这段代码还在」"]
    style OK fill:#e8f4e8
    style BAD fill:#f8d7da

12.2.2 覆盖率的门槛,以及它怎么算

门槛是 95%,但比这个数字更重要的是它怎么算。规则写得很细, 就是为了堵住”把分母做大”这条路:只针对新增和修改的生产单元, 不是全仓一刀切;用平台标准指标和工具能报告的最小范围; 先算原始的已覆盖除以总数再取整;只用工具链既有的排除项, 并记录验证命令与范围;不聚合无关代码抬高数字,不加排除项掩盖缺口; 而且覆盖率不替代有意义的断言与变异验证。

第三条容易被跳过,但它堵的是一个很实在的漏洞:先四舍五入再聚合, 和先聚合再四舍五入,在多个小文件上能差出好几个百分点。 最后一条是整段的兜底:覆盖率是一个下界指标,不是一个质量指标 —— 它能告诉你哪里肯定没测,不能告诉你测过的地方测对了。

12.2.3 测试结论属于跑测试的那一方

还有一条容易被忽略的纪律:测试结论属于跑测试的那一方, 后续阶段不得重新推导或覆盖。

听起来像废话,但在多阶段流水线里,“后面那一段自己判断前面那段其实没问题” 是很常见的绕过方式 —— 一个下游阶段看到上游失败了,检查一下发现 “这个失败看起来是环境问题”,于是继续往下走。

它之所以危险,是因为它每一次都显得合理,而且它把一个明确的判定 换成了一个推测。这条纪律由两条独立的路径不变量守着, 一条管客户端侧,一条管 CI 侧。

12.3 坑一:假绿 —— 跑了 0 个用例,照样通过

现在讲三个真实踩过的坑。它们比”要写测试”这句话有用得多, 因为它们都属于”看起来在跑,其实没跑”—— 而这恰恰是 Agent 最容易停在的状态。

12.3.1 形态一:过滤器写错,零个用例,报通过

测试过滤器给裸方法名会匹配不到任何用例,测试二进制以 0 退出, 构建系统报”通过”;要求跑 8 遍就报 8 个”通过”。

--test_filter='StreamingLogReaderTests'                       # ✅ 类名
--test_filter='StreamingLogReaderTests/testFinalLineIsRead'   # ✅ 类名/方法名
--test_filter='testFinalLineIsRead'                           # ❌ 0 个测试,照样通过

关键在于摘要那一行数的是构建目标,不是测试用例。所以规矩是: 带过滤器得到的红或绿,在看到”执行了 N 个测试”且 N 不为 0 之前都不算数。

bazel test //X:Tests --test_filter=... --test_output=all 2>&1 | grep "Executed [0-9]* test"
# Executed 0 tests, with 0 failures   ← 假绿

这个坑真正的杀伤力不在于它骗过了一次,在于它是上游污染。一旦踩中,后面所有依赖 “跑一次看结果”的动作 —— 包括变异验证和 flake 复现 —— 得出的结论全是空话

具体想一下这条链:你以为你在做变异验证,拿掉修复、跑测试、红了、很好; 而实际上你跑了零个用例两次,两次都”通过”,于是你得出 “这条测试守不住行为”的结论,然后去重写一条本来没问题的测试。 一个坏掉的探针,会让你在一个正确的东西上做优化。

12.3.2 形态二:编译通过,但没有可执行宿主

同一个形状的第二个实例,沉淀成了一条规则:界面测试只有库目标、 没有可执行宿主时,会编译通过但执行 0 个用例。

这一条更隐蔽,因为它不需要任何人写错什么 —— 只要构建文件里少了一个目标, 整个测试套件就静默地不跑了,而所有信号都是绿的。它和形态一的区别在于 触发条件:形态一要有人把过滤器写错,形态二只要有人忘了加一行构建声明。

12.3.3 形态三:无条件跳过

第三个实例也沉淀成了规则,它匹配无条件的跳过语句。规则的说明是: 无条件的跳过永久删除了覆盖,却仍然保留了测试的外观。

修法是把场景修好并启用,只有真实的运行时限制才允许用有依据的条件跳过。 「有依据」这三个字是关键 —— 一个条件跳过必须能说出它在跳过什么、 以及那个限制什么时候会消失,否则它和无条件跳过没有区别。

12.3.4 三个形态的共同结构

三种不同的原因,同一个后果,同一个信号:用例没被选中、用例没被执行、 用例被跳过 —— 而它们全都返回绿。

它们的共同点是:在”通过”这个信号里,没有携带”跑了多少”这个信息。 这就是形状 A 的本质 —— 探针和被测对象之间有一条未被验证的因果假设, 而这里的假设是”任务跑完了等于用例跑过了”。

12.4 坑二:本机全绿,只在 CI 红

本机和 CI 跑的根本不是同一件事。CI 用的是覆盖率模式,外面套了一层 语言环境包装器,还会按主机容量并发多个模拟器。

差异出现时,按可疑度从高到低排查这三个变量:模拟器争用(最常见)、 覆盖率插桩(它改代码生成,能翻出只在插桩下崩的问题)、 语言环境包装器(默认值和本机通常一致,最少中招)。

除此之外还有一层:CI 的模拟器通常比本机新,而声明式界面框架展平成 原生视图层级的方式会随系统版本变化,所以界面测试里的层级断言 可能只在某一边成立。

规矩是:本机绿是必要条件,不是充分条件。 而它不会自发地想到这一点 —— Agent 拿到一个绿灯之后,默认的下一步是报告完成,除非有东西告诉它 这个结论的适用范围。所以这句话本身就该进常驻文件, 而不是留在某个人的经验里 —— 留在经验里,它只对有那份经验的人生效。

12.5 坑三:用墙钟给异步上界

flake 的典型形状是这样的:要断言一个最终一致的异步副作用, 却用时间给它设超时。

问题在于墙钟在争用下是反向伸缩的 —— 机器越忙,同样一秒里 能排进去的调度次数越少。于是这条测试实际上是红在机器负载上, 而不是红在被测代码上,重跑一次又绿了,看起来就成了”偶发”。

规矩是:上界要用争用时真正稀缺的那种资源 —— 调度次数, 或者每轮真正发出一次的查询 —— 别拿时间赌。

12.5.1 这条纪律在工具链里执行得比在运行时里好

小节 9.7.1 数过这条纪律在生产代码里的执行情况: 真正的违规只剩一处,而它偏偏在最核心的那个运行时文件里, 那条路径还没有测试覆盖。

从测试这一章的角度看,那一处的位置本身就是个信号: 没有测试覆盖的地方,也是纪律最先松掉的地方 —— 两者不是巧合,是同一个原因的两个面。 没有测试盯着,“这里用了轮询”这件事不会在任何一次运行里表现出来。

12.6 三个坑的共同结构

三个坑分别是”没跑”、“跑的不是同一件事”、“红在负载上”。 把它们摆在一起,能看到一个共同的骨架:

你以为在测什么 实际在测什么
假绿 行为 测试目标存在
本机 vs CI 代码 本机的那套环境
墙钟超时 异步副作用 机器当时有多忙

三个都是”测量对象错了”,而不是”测量出错了”。

这个区分很重要,因为它决定了修法。测量出错了,修法是修测量的实现 —— 重试、加日志、改超时;而测量对象错了,修法是换一个测量对象。 人(和 Agent)的默认反应是前者 —— 看到 flake,第一反应是重试 或者把超时调大,那正是在一个错误的测量对象上做优化。

12.7 为什么 Agent 特别容易停在”看起来在跑”

这一节解释一个现象:为什么这三个坑对 Agent 的杀伤力比对人大得多。

人在遇到”测试通过了”的时候,会有一些说不清的怀疑 —— 这个功能这么复杂, 怎么一次就过了?这种怀疑来自经验,而且它经常是对的。

Agent 没有这个怀疑,而且它的工作循环鼓励它没有。它的循环是 改代码、跑检查、检查通过、报告完成 —— 而”检查通过”在这个循环里 是一个终止条件。一个终止条件被满足的时候, 没有任何机制会促使它继续追问。

“执行了 N 个测试且 N 不为 0”这条检查的分量就在这里: 它把一个隐含的假设变成了一个显式的终止条件。同样的逻辑 解释了变异验证的价值 —— 它在循环里插入了一步”证明这条测试有能力失败”, 而这一步的输出是一个新的、无法被绕过的事实。

12.8 构建文件里的成本论证

这一节讲一份包装规则的文档注释,因为它是整个仓库里最好的工程写作, 而且它示范了一件很少有人做的事:给一个默认值写期望值论证。

背景是界面测试在 CI 里不参与合批,所以一个测试目标就是一个 macOS 任务, 再乘以系统版本矩阵。macOS 执行机只有 5 台。

12.8.1 决定一:名字由包装器推出,不由调用方起

按功能拆 bundle 曾把 8 个产品拆成 30 个目标,而一次共享层改动 就要 43 个任务去抢 5 台机器

所以不按功能拆,而是按执行环境拆:目标名必须等于包装器从 owner 和设备推出的那一个。这条约束的执行方式很讲究 —— 两个只差过滤条件的目标会推出同一个名字,构建系统在加载期就报重复目标, 于是拆不出来,而不是拆完等 CI 发现

这是”让非法状态无法表示”用在了构建图上。它不是一条被检查的规则, 它是一条结构上做不到的事。

失去的能力(失败时只重跑一个业务域)被另一条路径补上了: 本地重跑时用命令行过滤。注释里写着”拆分的收益由命令行在本地重跑时提供, 不需要在构建图上永久付一份任务成本”——临时的需求用临时的手段满足, 不要为它在结构上留一个永久的口子。

12.8.2 决定二:超时默认值的期望值论证

框架默认的超时太紧,于是改成更长的一档。但注释接着说, 对”一个产品一个 bundle”来说这一档几乎总是不够,并给出了实测: 本地是 40 个用例 977 秒,约每用例 24 秒(不是早期文档写的约 7 秒); 而 CI 的 5 台机器各跑 3 个并发,3 个模拟器加 3 个运行器挤 16GB, 实测把同一个用例放大 1.3 到 5.4 倍 —— 所以本地低于阈值也不能赌。

然后是那句论证:

赌短超时是负期望:超时会让 flaky 的 3 次尝试各烧满 900 秒 (45 分钟一台机器),而长超时在测试正常通过时不产生任何额外成本。

这是一个不对称收益的判断。调短的收益是”失败时早点知道”, 代价是”每次失败烧掉 45 分钟机时”;调长的代价在正常情况下是

没有人给超时值写期望值论证。 而这正是这类默认值总是设错的原因 —— 它们是凭感觉设的,而感觉在不对称收益面前是不可靠的。人的直觉倾向于 把超时设紧,因为「早点失败」听起来总是对的;而这个直觉在收益对称时成立, 在这里不成立。

12.8.3 决定三:承认一条查过的死路

注释里还有一句:“没有第三条路:测试运行器不认构建系统的分片协议, 所以分片数拿不到,单个目标内部无法再切。”

记下一条被排除的方案,和记下选中的方案一样有价值。 没有这一句,每隔几个月就会有人(或某个 Agent)重新提议”用分片来解决”, 然后花半天验证它不行。

12.8.4 决定四:flake 的根因被命名了

界面测试默认标记为 flaky(允许重试),但注释没有停在”界面测试不稳定”, 而是点名了两类无法靠产品代码消除的基础设施 flake, 每一类都带着一个具体的案例编号。

“这个测试不稳定”和”这个测试因为这两个已知的基础设施问题不稳定” 是两个东西。前者是放弃,后者是一份待办清单 —— 而且它让”重试” 这个决定变得可以被复核:哪天这两个根因被消除了,这个默认值就该改回去。

12.8.5 它自己承认的一个缺口

同一份注释里还有这样一段:构建文件里的排除清单没有任何东西 校验其完备性,新加一个套件忘了同步就静默双跑或静默不跑

我在自己的文档里标出了一处形状 A。 它没有被修(可能因为成本高于收益), 但它被写了下来。写下来之后,任何一个读到这份文件的人(或 Agent) 在遇到”某个套件好像没跑”的时候,会立刻有一个候选解释 —— 一个已知的缺口,和一个未知的缺口,在排查成本上差一个数量级。

12.9 实测:这套东西守住了多少

前面讲的都是机制,现在看结果。全仓 Swift 测试里有 33,239 个测试函数, 其中 1,318 个没有任何断言 —— 4.0%。作为对照,一般代码库这个比例 在 10% 到 20% 之间。

但要补两句。第一,这个统计偏高 —— 它会把”断言封装在契约辅助函数里” 的测试误判为零断言,而我恰恰鼓励那种写法(比如那个”只提交一次购买意图” 的契约辅助函数),所以真实值比 4% 更低。

第二,零断言的浓度最高的地方,恰好是并发测试。比如微内核的 线程安全测试里,八个”完成”标记只对应五个断言。好的那几条非常对 —— “并发解析同一个服务,装配计数必须恰好是 1”是那个文件唯一真正重要的不变量, 而它被直接断言了。但另外两个测试只靠”不崩溃、不超时”过关, 还有一条断言”完成的操作数大于零”,基本不可证伪。

这里有一个值得单独说的现象:手艺最强的地方,验证反而最弱。

写出那个三态机的人知道它是对的 —— 他推理过所有的交错。“我知道它对” 会实实在在地降低写断言的动力,因为断言在他看来是在证明一件已经确定的事。 这是典型的专家盲区,而且它有一个很坏的性质:它精确地作用在系统里 最难改、最少人懂的那部分代码上。半年后来改这段代码的人(或 Agent) 拿不到那个”我知道它对”,他只有那两条不会失败的测试。

12.10 两条专门用来防”测试看起来通过了”的规则

这两条规则的完整定义值得摆出来,因为它们是”绿灯的可信度需要独立守卫” 这句话最具体的形态。

[[rule]]
id = "UI-E2E-NO-CONSTANT-SKIP-CONDITION"
kind = "forbid_pattern"
enforce = true
scope = ["Products/*/UITests/**/*.swift"]
source_view = "code_only"
pattern = '(?:XCTSkipIf\s*\(\s*true\b|XCTSkipUnless\s*\(\s*false\b)'
sentinel_min = 50
incident = "无条件 XCTSkipIf(true)/XCTSkipUnless(false) 永久删除了覆盖却仍保留测试外观"
fix_hint = "修复并启用场景;真实运行时限制使用有依据的条件 skip"

[[rule]]
id = "PAYWALL-E2E-NO-RETRY-TAP-HELPERS"
kind = "forbid_pattern"
enforce = true
scope = ["Products/*/UITests/**/*Paywall*.swift"]
pattern = '\b(?:tapCTAUntil|tapSubscribeUntil|tapStartUntil)\s*\('
sentinel_min = 20
incident = "已知 Paywall retry-tap helper 会把一次就绪延迟升级成重复交易,并在 CTA 消失动画期间触发 XCUITest ObjC 异常"
fix_hint = "删除 tapCTAUntil/tapSubscribeUntil/tapStartUntil;通过 PaywallContract.tapCTAOnceAndWait 只提交一次购买意图"

第一条盯的是无条件跳过。注意那个正则只匹配常量条件—— XCTSkipIf(true)XCTSkipUnless(false), 而不是所有的 skip。这个边界是刻意的: 真实的运行时限制(这台机器没有摄像头、这个系统版本不支持某个 API) 是合法的跳过理由,禁掉它会在正确的工作上误报, 然后这条规则会被绕过(小节 14.4)。 incident 那句话把这条规则的全部理由压成了一行 —— 它永久删除了覆盖,却仍保留测试外观。

第二条盯的是”点到成功为止”这类重试点击的辅助函数。 incident 里写了两个后果,而它们的性质完全不同: 一个是业务后果(把一次就绪延迟升级成重复交易,也就是重复扣款), 一个是技术后果(在按钮消失动画期间触发测试框架的异常, 表现为 flake)。 fix_hint 不只是说”别用”, 它指出了替代品的名字 —— tapCTAOnceAndWait, 一个只提交一次购买意图的契约方法。

这两处代码的共同点是它们都能编译、都能跑、CI 全绿。 人在评审里几乎不可能从一个叫”点到订阅成功为止”的函数名 联想到”这会重复扣款”。它们也不是代码风格问题 —— 它们是用测试的外观掩盖了覆盖的消失, 而这正是”绿灯到底可不可信”必须有一层独立守卫的原因。

还有一处细节值得学:两条规则的 scope 精度不一样。 第一条覆盖所有界面测试,第二条只覆盖文件名里带 Paywall 的那些。 后者更窄,因为那个问题只在支付路径上有业务后果 —— 而窄的 scope 直接降低了误报的可能, 也让 sentinel_min 可以设成一个更小但更有意义的数(20 而不是 50)。 一条规则的 scope 不是”能覆盖多少”,是”在哪些地方这条不变量真的成立”。

12.11 测试金字塔在 Agent 场景下的形变

经典的测试金字塔是一条建议:多写单元测试,少写端到端测试, 理由是上层贵、慢、脆。在 Agent 场景下,这条建议的理由变了, 而结论部分保留。

理由变了,是因为多了一个经典金字塔没有考虑的因素: 谁在写这些测试,以及他有没有动力让它们真的能失败。

一个人写单元测试时,他脑子里有那段代码的模型,他知道哪些边界值得测。 一个 Agent 写单元测试时,它的优化目标是”让检查变绿”—— 而单元测试是最容易被写成假绿的一层,因为它的输入完全由测试自己构造。

所以在 Agent 场景下,金字塔的每一层有了新的性质:单元层最便宜最快, 但最容易假绿(输入自造、断言自选);端到端层最贵最慢最脆, 但最难假绿(真实路径,难以伪造)。

这不是说要倒过来堆端到端测试 —— 成本的理由仍然成立。它说的是 单元测试那一层需要额外的守卫,而这正是变异验证和”执行数不为零” 这两条存在的理由。换句话说:金字塔的形状不变,但底座需要加固。

12.12 覆盖率在 Agent 场景下的贬值

再看一个指标的形变。

覆盖率一直有一个众所周知的局限:它衡量”跑到了多少行”, 不衡量”验证了多少行为”。在人写测试的场景下,这两者是弱相关的 —— 一个人跑到了一行代码,通常是因为他想验证那行代码。

在 Agent 写测试的场景下,这个相关性大幅下降。原因是提高覆盖率 有一条不经过”验证行为”的捷径:调用那个函数,然后断言它没有崩溃。 这条捷径能让覆盖率达标,而它验证的东西接近于零。

所以覆盖率规则里那六条限制中的最后一条是”覆盖率不替代有意义的断言 与变异验证”——这条不是免责声明,是承认这个指标已经不够用了。

它的替代品是变异验证:衡量”守住了多少行为”,而不是”跑到了多少行”。 两者的成本差别很大 —— 覆盖率是自动统计的,变异验证需要一次额外的操作 —— 但在 Agent 场景下,前者的信息量大幅下降,而后者没有。

12.13 变异验证很难自动化

我反复推荐变异验证,所以得说清楚它的困难。

手工的变异验证成本是零 —— 改 bug 时先让测试红一次, 而你本来就要跑一次。自动化的变异验证成本很高:你需要程序性地 生成代码变异(改一个比较符、删一行、反转一个条件),然后跑全部测试, 看有没有测试变红 —— 一次完整的变异验证可能要跑几千次测试套件。

我的做法是只在关键路径上手工做,而不是建一套自动化的变异验证基础设施。 这个取舍值得说清楚,因为它反映了一条更一般的原则:

一个昂贵但正确的判定,如果只在关键路径上施加, 仍然比一个便宜但不可信的判定覆盖全部要好。

“关键路径”在这里有一个具体的定义:每一次修复 bug 的时候。 因为那正是”这条测试守不守得住行为”这个问题最重要的时刻 —— 你刚刚证明了这个行为会出错,现在要确保它不会再错。

12.14 Agent 写的测试和人写的测试有什么系统性差别

列几条观察,因为它们决定了该怎么审。

Agent 写的测试数量多 —— 它没有”写测试很烦”这个阻力。 覆盖广度好 —— 它会把参数组合枚举得很全。 命名与组织好 —— 它擅长产生一致的结构。 断言深度浅 —— 倾向于断言”没崩溃”和”返回非空”。 边界情况中等 —— 会测明显的边界,不会测需要领域知识的。 至于它们能不能失败,未知 —— 而这是唯一真正重要的那一项。

前五项都是它的优势,第六项是它的盲区。这张对照给出了一个很具体的 审查策略:不要审 Agent 写的测试覆盖得全不全(它比你全), 审它们能不能失败。

“能不能失败”有一个几乎零成本的检验方式,就是变异验证 —— 在它写完之后,改坏一处被测代码,看有几条测试变红。 如果一条都没红,那这一批测试不管有多少条,守住的行为是零。

12.15 测试数量:一个反直觉的观察

我这套系统里测试代码占全部代码的三分之一以上。但这一章的所有内容 都在说明一件事:测试的数量和测试的可信度,是两个几乎独立的量。

一个有一万条测试的套件,如果其中有一千条从来不会失败,那么它提供的保护 和一个有九千条测试的套件完全一样 —— 而它的维护成本高 11%、 运行时间长 11%,并且你不知道是哪一千条。

在 Agent 大量产出测试的场景下,这个问题会加速:Agent 很擅长写出 语法正确、命名合理、看起来在测东西的测试;它不擅长(也没有动力) 判断这条测试是否真的能失败;而每一条新增的测试都会稀释你对整个套件的信任。

所以在这个场景下,“测试覆盖率”这个指标的价值下降了, 而”变异验证”这个指标的价值上升了 —— 前者衡量的是”跑到了多少行”, 后者衡量的是”守住了多少行为”,而只有后者在 Agent 大量产出的时候仍然可信。

12.15.1 那怎么发现那一千条

上面那个”你不知道是哪一千条”不该停在这里,因为它是可以被缩小的。 按成本从低到高,有三条路。

最便宜的一条是看它们的历史:一条从来没有红过的测试是可疑的。 版本控制里有这个数据 —— 一条测试如果从写下那天起 从来没有出现在任何一次失败记录里,它要么守着一个从来没被违反的行为, 要么它没有失败的能力。这两种在输出上无法区分, 但它给了你一份优先排查的名单,而这份名单通常比你预期的短。

第二便宜的是抽样做变异验证。 不需要全套自动化 (小节 12.13 讲过那很贵)—— 从那份名单里随机挑十条, 手工把它们守的那段逻辑改坏,看它们红不红。 十条里如果有三条不红,你就知道整份名单的大致成色了, 而这个抽样的成本是一个下午。

最贵的一条是给关键路径上的测试建立自动的变异验证。 它值不值取决于那条路径的代价 —— 小节 12.13 给出的判据是:只在”这段逻辑错了会不可逆”的地方做。

三条路的共同前提是你得先愿意承认那一千条可能存在。 这一点比任何一条技术手段都难,因为一个绿色的测试套件 是团队里少数几个不会被质疑的东西之一 —— 它每天都在提供一种”我们是安全的”的证据, 而那正是它最难被审视的原因。

12.16 测试代码占三分之一,这个比例意味着什么

这个数字容易被当成一个目标,而它不该是 —— 它是一个结果。

它的成因是:每一次修 bug 都要求变异验证,而变异验证意味着 每一个被修的 bug 都留下了一条能失败的测试。所以这个比例真正衡量的 不是”我们很重视测试”,而是”我们修过多少个 bug, 而且每一个都留下了守卫”。

这也意味着这个比例不该被当成新项目的目标。一个新项目没有那些 bug, 所以它不需要那些测试 —— 按比例去凑,凑出来的一定是没有守住任何行为的那种。

一个更有意义的指标是:有多少条测试,是在某次真实的 bug 修复中诞生的? 因为那些测试有一个别的测试没有的性质 —— 它们守的是一个已经被证明会出错的行为。

12.17 测试的三种读者

一条测试同时被三种读者消费,而它们要的东西不一样 —— 这个分歧解释了很多关于”测试该怎么写”的争论。

第一种读者是 CI,它只要一个布尔。对它来说, 测试的名字、组织方式、可读性全都无关紧要, 只有”它能不能失败”有意义 —— 这一章前面讲的全部纪律 服务的都是这个读者。

第二种读者是修它的人(或 Agent)。测试红了之后, 它要回答的是”哪里错了、错成什么样”。 对这个读者来说,断言失败时的输出比测试本身更重要: 一条断言 XCTAssertTrue(result.isValid) 和一条 XCTAssertEqual(result.errors, []) 在功能上等价, 而后者在失败时会告诉你具体是哪几条错误 —— 同样的覆盖,不同的信息量。 这一条在 Agent 场景下的收益被放大了, 因为 Agent 不会去打断点看变量,它只有那行输出。

第三种读者是想理解这段代码的人。 对它来说, 测试是一份可执行的规格 —— 小节 12.18 讲的正是这个。 这个读者要的东西和前两个有一处冲突: 它要覆盖典型场景,而前两个要覆盖边界场景。 一份全是边界值的测试套件在验证上很强, 但它读起来完全说不清这个功能正常情况下是干什么的。

三种读者的存在给出一个实用的建议:至少写一条 “这个功能正常工作时长什么样”的测试, 哪怕它在覆盖率上完全冗余。它的价值不在验证, 在于它是那份规格的第一段 —— 而在一个 Agent 会来读这段代码的仓库里, 那一条测试省下的猜测比它自己的运行成本高得多。

12.18 测试的另一个作用:它是给 Agent 的规格

这一章到这里讲的都是”测试作为判定”。还有一个作用值得说, 因为它在 Agent 场景下比在人的场景下重要得多:一条测试同时是一份规格。

Agent 读规格的方式和人不一样:人读文档,Agent 更倾向于读代码 —— 而测试是最接近”可执行的规格”的代码。

这有两个实际后果。第一,测试的可读性变成了一个功能性需求。 一条断言写成 XCTAssertEqual(result, expected) 和写成 XCTAssertEqual(result.status, .refunded, "退款后状态必须是 refunded"), 对判定来说是等价的(都能失败),但对 Agent 来说信息量差很多 —— 后者告诉了它这个行为的意图,而意图是它在修改相关代码时需要的。

第二,测试的组织方式变成了一份领域地图。一个按业务场景组织的 测试套件,Agent 读一遍就知道这个模块有哪些场景;一个按类和方法组织的, 它只知道有哪些函数。

这不是”写测试的最佳实践”,这是”给 Agent 的规格”这个新用途 带来的新要求。

12.19 测试该在什么时候被删掉

这一章讲了很多”怎么让测试可信”,但没讲删除 —— 而一个只进不出的测试套件会遇到和规则集一样的问题 (小节 15.10)。 三种该删的情况,而它们的判据都很硬。

它测的行为已经不存在了。 这一种通常会自己暴露 —— 那段代码被删了,测试编译不过。它的变体不会: 那段代码还在,但它已经没有任何调用方了, 于是测试还在绿着,守着一段死代码。 判据是看被测的东西有没有生产调用方, 而这个查询在有依赖图的仓库里是一条命令。

它和另一条测试测的是同一件事。 重复的测试不是免费的: 它们会一起红,于是每次失败要多读一份输出; 它们会一起被改,于是每次重构要多改一处。 判据不是”名字像”,是”把被测代码改坏, 它们是不是总是一起红”—— 这正是变异验证的副产品, 做一次就能同时发现假绿和重复。

它守的东西已经被结构性地保证了。 这一种最值得庆祝, 也最少发生:一条测试守的不变量后来被类型系统、 或者一条构建规则接管了(小节 15.8)。 这时候那条测试变成了纯粹的成本,而删掉它需要有人主动去做 —— 和删掉一条规则一样,没有任何机制会提醒。

三种的共同点是它们都不会让 CI 变红, 所以删除永远排在优先级末尾。唯一能改变这一点的做法, 是把”这个季度删掉了几条测试”当成一个会被看的数 —— 和 小节 15.13 那张清单是同一个思路。

12.20 ⚙️ 小规模怎么做

这一章里几乎没有一条需要基建。四件今天就能做的:

一、在 CI 里加一行,断言测试执行数不为零。 半小时。这是全书投入产出比最高的一条改动。

二、每次修 bug 时,先让新测试红一次再让它绿。 这就是变异验证的最小形态,成本是零 —— 你本来就要跑一次。

三、grep 你的测试里的无条件跳过。 通常能找出一批,而且每一个背后都有一个”当时说好回头修”的故事。

四、给你最慢的那个测试任务的超时值,写一句期望值论证。 调短的收益是什么,代价是什么?调长的代价在正常情况下是多少? 大部分人做完这个计算会发现自己的超时设反了。

12.21 测试基础设施本身该被怎么对待

这一章讲了很多关于测试的纪律,但有一个层面没讲: 跑测试的那套东西自己。 测试运行器、测试宿主、fixture 生成、CI 的编排逻辑 —— 这些代码不产生任何业务功能,而所有的测试结论都建立在它们之上。

它们通常没有任何判定覆盖。 小节 12.8.5 里那个自己承认的缺口就是一个实例: 构建文件里的排除清单没有任何东西校验它的完备性, 新加一个套件忘了同步就静默双跑或静默不跑。

这是形状 A 在测试基础设施上的形态, 而它比业务代码里的形状 A 更危险, 因为它影响的是所有测试的结论,而不是某一个功能。

12.21.1 三条可以立刻做的

一、断言测试目标的数量。 和哨兵下限是同一个思想:如果你的仓库有 200 个测试目标, 断言这个数不会突然掉到 150。

二、给测试宿主加一条”我还活着”的测试。 一条永远应该通过的测试,加一条永远应该失败的测试 (用预期失败标记)。如果第二条突然通过了,说明宿主坏了。

三、定期跑一次全量,对照分片跑的结果。 稀疏测量(小节 7.2)的正确性依赖依赖图的准确性, 而依赖图会因为动态加载、反射、配置驱动的依赖而不准。 定期对拍是唯一能发现这类偏差的方式小节 18.2.3)。

12.22 关于测试的一个常见误解

最后处理一个误解,因为它会让这一章的建议被误用:

“这一章是在说要写更多测试。”

不是。这一章从头到尾没有一处建议”多写测试”。

它讲的全部是已有的测试可不可信: 跑了没有、断言有没有能力失败、 它测的是代码还是环境、它在别的机器上是不是同一件事。 理由是:在 Agent 场景下,测试的数量不是稀缺资源, 测试的可信度才是(小节 12.15)。

一个 Agent 可以在十分钟内写出一百条测试。 让那一百条里有五十条真的能失败,需要的是机制,不是勤奋。

所以这一章的所有建议都是关于机制的: 执行数断言、变异验证、防假绿的规则、成本论证。 没有一条是”更努力地写测试”。

12.23 和第九章的一处呼应

小节 9.7 里那个伪本地化审计流水线的例子 —— 用固定的 sleep 改成盯日志里的路由事件 —— 和这一章的坑三(小节 12.5)是同一条纪律。

两处的出现顺序值得注意:那个流水线的改造, 比这一章讲的那条微内核轮询的修复要早。

也就是说:同一条纪律,在工具链里已经执行了, 在最核心的运行时里还没有。

小节 9.7.1 解释了原因(看得见的先修), 但这里可以补一层:

工具链里的违规会立刻产生可见的后果 —— 审计流水线出假阴性,有人会注意到。

微内核那个轮询在正常情况下几乎不会触发 (它只在两个线程同时解析同一个未装配的服务时才走到)。

所以它的违规不产生任何后果,直到某一天在高负载下产生了。

这正好是形状 A 的一个变体,而且是最难对付的那个: 不是探针坏了,是这段代码本身处在一个没有探针的位置。

它的解法不是”更严格地要求自己” —— 是给那条路径加一个测试(那条路径现在没有测试覆盖)。

一条没有测试覆盖的代码路径,它的纪律执行情况是不可观测的 (小节 19.1)。

12.24 这些机制的实际效果

这一章讲了很多机制,那么它们的实际效果如何?

可核验的数字:33,239 个测试函数,4.0% 零断言 (而这个统计偏高,小节 12.9)。

而不可核验的是最重要的那个数缺陷逃逸率 —— 有多少问题是合并之后才被发现的(小节 21.1.1)。

我没有这个数,所以这一章的所有机制, 它们的实际效果是未被证明的。

能说的只有:这些机制针对的失败模式是真实的 (每一条都对应一次具体的踩坑), 而机制本身在按设计工作(有输出、有拦截记录)。

“针对的问题是真的”和”解决了那个问题”之间, 还差一个数 —— 而那个数值得被补上, 因为它是唯一能验证这一整章的东西。

12.25 压成三句话

一、绿灯本身需要被验证。

三个坑(假绿、本机≠CI、墙钟超时)的共同结构是 “你以为在测 A,实际在测 B”小节 12.6)—— 而这在 Agent 场景下是主流失败模式,不是边缘情况。

二、衡量”守住了多少行为”,不是”跑到了多少行”。

覆盖率在 Agent 场景下贬值了(小节 12.12), 而变异验证没有 —— 因为它衡量的是测试有没有失败的能力, 而那正是唯一重要的那个性质。 三、这一章讲的全部是机制,没有一条是”更努力地写测试”。

因为测试的数量不是稀缺资源,测试的可信度才是小节 12.22)。

12.26 一张速查表

把这一章的可执行部分压成一页:

症状 先查什么
测试通过但功能不对 执行数是不是零
本地绿 CI 红 并发争用 → 插桩 → locale → OS 版本
同一份代码时红时绿 失败率和机器负载相关吗
加了测试但 bug 还是漏了 拿掉修复,那条测试会红吗
CI 老是红但不是代码的错 退出码有没有区分基建故障
测试越来越多但信心没涨 抽查十条,有几条真的能失败

六行,覆盖了这一章的全部实用内容。 它们的共同点是:每一行的”先查什么”都比 “重跑一次”或”调大超时”便宜,而且能给出真正的信息。