那天我先看到的是一片红灯:全量测试报出18个失败,4826个通过。可是把其中一个失败单独拿出来跑,它又是绿的。这样的结果很容易把人的注意力带到断言本身,怀疑是不是某个测试偶尔不稳定,或者先给它加一个跳过标记,等以后再处理。
我没有先改断言,而是把问题拆成3种运行方式:单独跑、全量串行跑、并行跑。因为“单跑通过、全量失败”已经说明,测试逻辑不一定是第一嫌疑人,运行它们的环境可能在测试之间留下了东西。并行一旦把这种东西放大,红灯反而成了线索。
先把红灯按运行方式分开
第一轮复现时,计划里记录的是17个失败,实际初始结果却是18个失败、4826个通过,还有1个跳过。多出来的那个并不是新问题,它和另一个失败共享同一条根因。这个小差异提醒我,不能只照着旧记录逐项打勾,必须把当前结果完整保存下来。
我先看模板批处理相关的4个测试文件,它们单独组成的小范围回归是183个全绿。再看全量,失败集中在几组不同功能里。这里已经出现了一个重要分叉:如果所有失败都指向同一段最近改过的代码,才适合优先追回归;如果失败分布很散,却在全量收集或并行时出现,就应该先查共享状态。
单跑通过并不能证明测试隔离,反而可能说明它依赖了一个干净而偶然的起点。
如果不做这一步分类,最容易发生的事情是把不同病因的失败揉成一团。一个过期基线、一个模块导入污染、一个已经被清理的证据文件,最后都可能被笼统地叫作“并行不稳定”。名称变得简单了,修复却会走错方向。
过期基线不是回归,先证明它从未正确
第一类红灯来自一个静态界面检查。断言写的是“未加保护的界面操作不能超过101处”,实际扫描结果是185处。表面看,最直接的办法是把185改成一个更大的数字,或者开始逐个给操作补保护。但这两个动作的成本和含义完全不同。
我回看了写下101这个数字的历史版本,发现那棵树当时实测已经是182。扫描规则没有变,扫描范围也没有扩大,当前185个命中里还有37个文件在更早之前就没有再改过。也就是说,101不是被最近改动推高的旧基线,而是从一开始就没有和实际测量对齐。
我选择把基线重锚到185,并把测量依据写进测试旁边,是因为这次证据只支持“纠正起点”,不支持“完成一次大规模界面治理”。断言本身没有被削弱;以后再增加1个命中,仍然会红。只有历史测量证明基线失真时,更新基线才是修复,而不是给失败换一个更宽的出口。
更新基线的前提不是“现在想让它通过”,而是证明旧基线从来没有代表真实状态。
这条判断也适用于其他质量门。一个数字如果没有留下测量范围、时间和复现方法,就很容易从事实变成装饰。测试保护的不是某个漂亮数字,而是数字背后那条可重复的边界。
裸模块让兄弟模块互相覆盖
第二类失败更接近大家熟悉的“单跑绿、全量红”。两个相邻模块里都放了一个同名的公共模块,名字非常短,导入时也没有带自己的包前缀。某些脚本为了临时加载其中一方,把它的脚本目录永久插入模块搜索路径。
单独运行时,搜索路径里只有一个候选,所以看不出问题。全量收集时,测试框架会先导入大量测试模块;只要另一方先被导入,同名模块就会留在进程缓存里。后面本来要读取另一份公共函数的代码,拿到的却是前一份模块,最终在一个看似无关的安装流程里报错。
这里最容易误判的是“失败发生在哪个测试,就修哪个测试”。如果给被害测试补清理、补重载,甚至在开头强行删除模块缓存,可能只会让当前顺序恢复正常,下一种收集顺序仍然会坏。真正需要治理的是借用路径和模块缓存的生命周期。
我把借用动作缩到导入期间:临时挂上目标目录,完成导入后恢复原来的搜索路径和同名模块缓存。对于依赖延迟导入的地方,则不能机械地把路径立刻撤掉,而要把延迟导入一并放进临时作用域。这样做的理由很简单:隔离必须成立,但原有的运行时依赖也不能被误删。
表面上我是在修一个导入错误,其实是在修边界。一个模块能不能被别人借用,不只取决于它能否加载,还取决于加载完成后有没有把身份留在全局环境里。
并行暴露了真正的共享租约
串行问题处理完后,并行执行仍然会随机失败。失败的测试会申请一把真实的设备租约:进程内先检查状态,又通过子进程执行申请和释放。默认情况下,所有 worker 都把租约根目录落到同一个临时目录,设备标识也一样。串行时,申请和释放通常能排成一对;并行时,两个 worker 同时抢同一把租约,输的一方就收到互斥失败。
这次失败测试每次不一定相同,正是共享状态的典型信号。若把它当作偶发断言错误,可能会给测试增加重试,或者把互斥失败吞掉。这样做只会降低报警的灵敏度:真实的资源竞争还在,只是更晚才暴露。
我选择只在测试环境注入 worker 独立的租约目录,不改生产默认值,也没有把真实设备逻辑替换成假对象。第一版看似已经设置了环境变量,实际仍然会红,因为 worker 会继承控制进程的环境。后来才区分“用户原本设置的值”和“测试为当前 worker 设置的值”,让每个 worker 重新生成自己的目录。
并行测试的隔离不是把进程分开就够了,文件、环境变量、锁和外部租约也必须按 worker 分开。
如果不处理,红灯会随着机器负载和调度顺序变化,开发者既无法稳定复现,也无法判断某次绿灯是否可信。并行速度带来的收益,最后会被重复调查和错误重试吃掉。
证据治理也要区分真缺口和误报
剩下的一组失败看起来像证据文件问题。有些离线测试引用了已经被清理的文件,有些旧路径确实不再存在,还有一部分只是扫描器把测试夹具中的示例路径当成了真实引用。这里不能因为扫描器报了缺失,就把所有文件重新补回来;也不能为了让扫描器安静,直接删掉测试里的引用。
我把每个引用分成3类:测试运行时真的要读取的证据、历史记录明确说明已经丢失的证据、只存在于夹具文本中的示例路径。前一类从历史版本中恢复真正需要的少量文件;后一类如实登记为证据缺失;最后一类修正扫描范围,让它不要把自己的示例当成工作区依赖。真实缺口保留,误报去掉,原有的严格断言不动。
这个过程让我重新意识到,测试通过不等于证据被重新创造。两张历史截图曾经确实找不到,后来又从可恢复位置回来,记录也随之改回真实状态。证据治理的价值,正在于允许系统说“我没有这份材料”,而不是用一个看起来完整的空壳替代它。
下次我会怎么做
下次遇到“单跑通过、全量或并行失败”,我会先按顺序问4个问题:失败是否能稳定复现,问题发生在收集还是执行,环境变量和临时目录是否共享,导入缓存和证据路径是否跨测试泄漏。只有把这4层分开,才知道下一步是在修基线、修隔离,还是修证据记录。
我会保留单跑、全量串行和并行3份结果,并把失败数量的差异写清楚。历史基线要回到引入它的版本实测;共享模块要在借用期间恢复现场;真实租约要按 worker 分目录;证据扫描则要把真引用、丢失记录和示例夹具分开。
最终回归达到4889个通过、1个跳过,没有删除测试,也没有新增跳过标记。这个结果让我更愿意把测试看成一套观察系统,而不是一扇只负责显示绿灯的门。真正可靠的修复,不是让失败消失,而是让每一次失败都更接近它真正代表的状态。
并行开启之后,速度确实变快了,但更重要的是,隐藏的共享状态终于被照亮。以后再看到一开并行就红,我不会先问“能不能让它别红”,而会先问:到底是哪一份状态,本来就不该被下一个测试看见?