Skip to content

重跑一次就过了,这个缺陷去哪了 ​

一个测试失败了:期望汇率 712,实际得到 0。重新跑一遍,通过了。把构建配置成「失败的测试自动重跑 2 次」,之后这个测试再也没有让构建变红过。被测的代码一行没改,第一次查询返回 0 的问题还在。

「偶尔失败、重跑就过」的测试通常被归为测试不稳定,处理方式是加重跑。但偶发失败有三种完全不同的来源:产品代码里有只在特定时序或状态下才出现的缺陷,测试自己写得有问题,或者运行环境出了状况。重跑对三种来源一视同仁,把它们都变成绿色;其中第一种,正是测试存在的理由。

本文用 Maven Surefire 的重跑机制演示这个过程,说明重跑到底改变了什么,以及怎样在保留重跑的同时不让失败消失。实验见文末。

一、同一个缺陷,三种构建结果 ​

被测代码是一个第一次查询时才加载的汇率表。缺陷在于:发现还没加载时触发了加载,却没有等它完成,直接用了手里的旧值。

java
public static int rate(String currency) {
    Map<String, Integer> current = table;      // 先拿到了还是空的表
    if (!loaded) {
        load();                                // 加载,替换了 table
    }
    return current.getOrDefault(currency, 0);  // 用的还是空表
}

进程启动后的第一次查询返回 0,之后都正确。测试只有一行断言:assertEquals(712, RateTable.rate("USD"))。

配置测试统计构建结果
不重跑Tests run: 1, Failures: 1失败
rerunFailingTestsCount=2Tests run: 1, Failures: 0, Flakes: 1成功
再加 failOnFlakeCount=1Tests run: 1, Failures: 0, Flakes: 1失败:There is 1 flake and failOnFlakeCount is set to 1

第二行里,Surefire 在同一个进程里把失败的测试又跑了一次。这时汇率表已经加载过了,测试通过。控制台上留有记录:

text
[WARNING] Flakes:
[ERROR]   Run 1: RateTableTest.usdRate:10 expected: <712> but was: <0>
[INFO]   Run 2: PASS
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Flakes: 1
[INFO] BUILD SUCCESS

失败数是 0,构建成功,退出码是 0。第一次失败的信息在一条警告里,XML 报告里对应一个 flakyFailure 元素。只看构建状态和失败数的流水线、看板、合并检查,都看不到它。

而这个缺陷在生产环境里的表现是:每次发布或重启之后,第一个查询汇率的请求按 0 计算。

二、重跑改变的是放行概率 ​

上面的缺陷是确定性的(进程里第一次必错),真实的时序缺陷更常见的形态是按一定比例出现。设一个缺陷每次运行有 p 的概率让测试失败,允许重跑 k 次,那么构建失败(缺陷被拦住)的条件是连续 k + 1 次都失败,概率是 p 的 k + 1 次方。

构建失败(缺陷被拦住)的比例每次运行 30% 概率失败的缺陷30.0%不重跑9.0%重跑 1 次2.6%重跑 2 次0.8%重跑 3 次每次运行 10% 概率失败的缺陷9.9%不重跑1.0%重跑 1 次0.08%重跑 2 次0.008%重跑 3 次拦截率 = p 的(重跑次数 + 1)次方。模拟假设各次运行相互独立线上每一次请求都是一次「运行」:测试里 10% 才出现一次的问题,在每秒上千请求的服务里每秒出现上百次Surefire 的 failOnFlakeCount 可以让「先失败后通过」重新变成构建失败
图 1 · 实测(固定种子模拟 10 万次构建):一个缺陷每次运行有 30% 的概率让测试失败,不重跑时 30% 的构建会被它拦下;重跑 1 次降到 9%,重跑 2 次降到 2.6%,重跑 3 次不到 1%。重跑没有修复任何东西,只是把「至少失败一次」换成了「连续失败才算数」。缺陷出现得越不频繁,重跑后越接近完全放行。
每次运行失败的概率不重跑重跑 1 次重跑 2 次重跑 3 次
0.550.2%25.2%12.5%6.3%
0.330.0%9.0%2.6%0.8%
0.19.9%1.0%0.08%0.008%
0.022.0%0.04%0.002%0

(固定种子模拟 10 万次构建得到的构建失败比例,与公式一致。)

两点值得注意:

  • 不重跑时,一个 10% 概率的缺陷也要平均 10 次构建才暴露一次。它会以「偶尔红一下」的形式反复出现,这本身就是信号。重跑 2 次之后,1000 多次构建才红一次,信号基本消失。
  • 测试里 10% 才出现一次的问题,在生产环境里并不罕见。测试每次构建只运行一遍,线上每个请求都是一次运行。

三、先分清是哪一种 ​

偶发失败按来源分三类,处理方式不同:

来源典型原因重跑的效果该做什么
产品缺陷竞态、初始化顺序、超时边界、缓存未命中时的分支把缺陷放行当作缺陷修复;能稳定复现之前保留全部现场
测试自身用固定时长的等待代替等条件成立、依赖执行顺序、共用数据、依赖当前时间或时区、随机数没有固定种子有时通过,有时让情况更糟修测试:等条件、隔离数据、注入时钟、记录种子
环境构建机资源争用、网络抖动、依赖的外部服务不可用通常有效可以重跑,但要分类统计;减少测试对外部环境的依赖

顺序依赖的例子。 两个测试用了同一个进程内共享的库存表和同一个商品编号。第一个测试入库 5 件,第二个测试入库 3 件并断言库存是 3。

  • 单独运行第二个测试:通过。
  • 两个一起运行、重跑 2 次:第二个测试三次都失败,实际值依次是 8、11、14。

每重跑一次,测试又往共享状态里加了 3。重跑不仅没有帮助,还在改变现场。这类失败的特征是「单独跑通过、一起跑失败」或者「换个顺序就变」,根源是测试之间共享了可变状态(静态变量、同一个数据库里的同一行、同一个临时文件)。修法是让每个测试使用自己的数据,或者在测试前把状态重置到已知值。

分类的依据是第一次失败时留下的信息,所以下一节的事要先做。

共享数据库中的准备、断言和清理需要使用同一数据归属;运行标识、事务回滚和独立数据库分别覆盖哪些干扰,见测试数据的隔离范围。

四、保留第一次失败的现场 ​

偶发失败最难的地方是复现。第一次失败时的信息如果被重跑覆盖掉,就只能等它下一次出现。需要保留的东西:

  • 第一次失败的断言信息与堆栈。Surefire 把它们写在 flakyFailure 元素里;流水线要归档原始的 XML 报告,而不只是最终状态。
  • 失败时的执行顺序、并行度、随机种子。使用随机顺序或随机数据的测试必须把种子打印出来,否则「随机」就等于不可复现。
  • 被测进程在失败时刻的日志。按时间戳或请求标识能对上测试步骤。
  • 环境信息:构建机、同时在跑的其他任务、依赖服务的版本。

界面自动化还要加上失败时的截图、页面结构与网络请求记录,原则相同:证据在第一次失败时采集,重跑不覆盖它。

五、让重跑保持可见 ​

重跑本身不是问题,把它的结果等同于「通过」才是。几个做法:

把 flake 计入结果。 Surefire 的 failOnFlakeCount 可以设定允许的 flake 数量上限,超过就让构建失败。如果暂时做不到零容忍,至少把每次构建的 flake 列表汇总出来,按测试统计出现频率。

隔离要有期限和负责人。 确认是测试自身的问题、一时修不好,可以把它移出阻塞合并的集合(单独的分组或标签),但要登记原因、负责人和截止日期,并且让它继续在非阻塞的任务里运行以积累数据。没有期限的隔离等于删除测试。

只对归类为环境的问题自动重跑。 能从错误类型判断出是环境问题(连接被拒绝、构建机资源不足、依赖下载失败)时再重跑;断言失败默认不重跑。

不要在更高一层重跑。 在流水线层面「整个任务失败就重新触发」,会把测试框架层面留下的 flake 记录也一并抹掉,是可见性最差的一种重跑。

新出现的偶发失败优先处理。 一个之前稳定的测试开始偶尔失败,最可能的原因是最近的代码改动引入了时序问题,这时离原因最近、最容易定位。


配套实验

  • codesphere-labs/testing/retry-hides-failures:Maven Surefire 3.6.0 与 JUnit Jupiter 6.1.3 下同一个缺陷在不重跑、重跑 2 次、failOnFlakeCount=1 时的构建结果与报告内容,顺序依赖的测试一起运行与单独运行的结果,以及重跑次数与拦截率的模拟(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权