凌晨三点,CI流水线又红了。你揉了揉眼睛,点开失败日志——是那个该死的支付模块测试。昨天它完美通过,今天莫名其妙挂掉,而你一行代码都没改。

重跑一次。通过。

这听起来像是一个笑话,但它是全球数百万开发者的日常。2016年,Google公开了一组令人震惊的数据:在他们庞大的测试系统中,约1.5%的测试运行结果是不稳定的,更有甚者,84%的"从通过变失败"的状态转换,都是由这些不稳定测试引起的。换句话说,当CI告诉你"有东西坏了",绝大多数时候它其实在撒谎。

这种被称为Flaky测试(不稳定测试)的现象,正在悄然侵蚀着整个软件行业的测试基础设施。

一个名字背后的普遍困境

Flaky测试的定义简单而残酷:在代码和测试本身没有任何变化的情况下,它既可能通过,也可能失败。没有规律,没有预兆,只有纯粹的随机性。

2011年,Martin Fowler在他的经典文章中发出了警告:不稳定测试具有传染性。当一个测试套件中有10个不稳定测试时,整个套件就会频繁失败。起初,人们还会仔细检查哪些是真正的失败,哪些只是噪音。但很快,这种纪律就会瓦解——健康的测试失败也会被一并忽略。到了那一刻,整个测试套件的价值就归零了。

这个警告并非危言耸听。2022年的一项多源文献综述分析了651篇文章(560篇学术论文和91篇灰色文献),发现59%的开发者声称每月、每周甚至每天都会遇到Flaky测试。在被问及严重程度时,91%至少偶尔遇到Flaky测试的开发者中,56%认为这是"中等问题",23%认为是"严重问题"。

问题的规模远超想象。Google的报告显示,他们约16%的测试都具有某种程度的不稳定性——这意味着每7个测试中就有1个可能不可靠。GitHub在2020年的数据同样触目惊心:9%的提交至少有一个由Flaky测试导致的红色构建

十大成因:为什么测试会"精神分裂"

2014年,伊利诺伊大学的研究团队对Apache项目进行了首次系统性实证研究,分析了201个修复Flaky测试的提交。他们识别出了10个主要成因类别,这个分类至今仍被广泛引用。

异步等待:头号杀手

异步等待问题(Async Wait)占据了Flaky测试成因的近一半。当测试发起一个异步调用后,没有正确等待其完成就开始验证断言,结果就取决于异步操作是否及时完成。

典型的反模式是使用固定延迟:

// 错误示范:硬编码等待时间
Thread.sleep(1000);  // 希望1秒足够
assertThat(result).isEqualTo(expected);

问题在于,“足够"是一个不存在于工程世界中的概念。在本地开发环境,1秒可能绰绰有余;但在CI环境的虚拟机上,同样的操作可能需要1.2秒。于是,测试在本地通过,在CI上失败——经典的Flaky行为。

正确的方法是使用回调或轮询:

// 正确做法:轮询直到条件满足或超时
Awaitility.await()
    .atMost(5, TimeUnit.SECONDS)
    .pollInterval(100, TimeUnit.MILLISECONDS)
    .until(() -> service.isReady());
assertThat(service.getResult()).isEqualTo(expected);

并发问题:时序的诅咒

竞态条件(Race Condition)是第二大成因。当多个线程以不可预测的顺序访问共享资源时,测试结果就取决于线程调度的随机性。

微软的研究团队对内部项目进行了大规模分析,发现异步调用是导致Flaky测试的主要原因,这与Google的发现一致。更深层的问题在于,竞态条件不仅存在于测试代码中——它可能潜伏在被测代码本身,而测试只是将其暴露出来。

测试顺序依赖:隐形的污染者

测试顺序依赖(Order Dependency)是最隐蔽的成因之一。当测试A修改了某个共享状态,测试B依赖这个状态时,执行顺序就决定了结果。

研究人员将这类测试分为三类:

  • 污染者(Polluter):修改了共享状态,导致后续测试失败
  • 受害者(Victim):依赖被污染的状态,在污染者之后执行会失败
  • 清理者(Cleaner):能清理被污染的状态,使受害者恢复正常

一个令人不安的发现是:76%的顺序依赖测试只与另一个测试相关。这意味着问题不是复杂的依赖网络,而是简单的两两关系——但正因为简单,反而更容易被忽视。

网络、时间与平台依赖

网络问题占Flaky测试成因的约8%。当测试依赖外部API、数据库连接或第三方服务时,网络延迟、服务不可用或响应格式变化都可能导致不可预测的失败。

时间依赖同样棘手。测试可能依赖系统时间、时区或日期边界。一个在下午3点通过的测试,可能在午夜前后莫名其妙地失败。

平台依赖则源于对未定义行为的假设。当测试依赖HashMap的迭代顺序(Java规范中未定义)、依赖特定浏览器的渲染行为、或假设特定硬件性能时,它在不同环境下就会表现不同。

随机性与机器学习的独特挑战

随机性是Flaky测试的传统成因。当测试使用随机数据但未固定随机种子时,不同运行可能产生不同输入。

但在机器学习领域,随机性问题的规模被放大到了新的高度。研究表明,60%的机器学习项目中的Flaky测试源于算法非确定性。神经网络训练过程本身具有随机性——权重初始化、数据洗牌、dropout层等都会导致输出波动。当测试断言期望一个"足够接近"的结果时,这个"足够"本身就是一个模糊的定义。

隐形成本:不仅仅是浪费时间

Flaky测试的直接成本显而易见:浪费CI资源、开发者时间、延误发布。但更深层的影响往往被忽视。

信任的瓦解

当一个测试套件频繁发出假警报时,团队对整个测试系统的信任就会下降。Spotify的工程团队描述了这种现象:

“如果你不信任你的测试,那么你的处境并不比一个没有任何测试的团队好多少。不稳定测试会严重影响你持续交付的信心。”

更危险的是,真正的bug可能被隐藏在噪音之中。Google的报告中提到,开发者在看到失败后,第一反应往往是"又是一个Flaky测试”,然后重跑——即使这次失败是真的bug导致的。人类的本能是忽略频繁误报的警报系统,这在航空、医疗等领域已经导致了灾难,在软件开发中同样如此。

技术债务的累积

不稳定测试会像滚雪球一样积累。一个未被修复的Flaky测试可能掩盖新引入的Flaky行为;当开发者习惯了忽略某些测试的失败时,新的问题就更容易被遗漏。

一些组织报告称,他们的Flaky测试比例已经超过了50%,开发者几乎不再编写测试,也不关心测试结果。测试,作为质量保障的核心手段,彻底失去了意义。

检测:从被动发现到主动出击

重跑策略:最原始但有效的方法

检测Flaky测试最直接的方法是多次运行同一个测试。如果它在相同条件下既通过又失败,就可以确认是Flaky的。

Google采用了更精细的策略:当一个测试从通过变为失败时,系统会自动重跑10次。如果任何一次重跑通过,就将其标记为Flaky。这个方法简单有效,但代价高昂——每次检测都需要额外的CI资源。

工具生态:从研究到实践

学术研究催生了一系列检测工具:

iDFlakies通过随机化测试执行顺序来检测顺序依赖的Flaky测试。它在694个Java项目中发现了422个Flaky测试。

NonDex专注于检测对未定义行为的假设。它通过改变Java标准库中非确定性数据结构(如HashMap)的内部实现来暴露隐藏的依赖。

DeFlaker采用差分覆盖策略——如果一个测试在不同运行中产生不同结果,但覆盖的代码没有变化,就可以判定为Flaky。这避免了昂贵的重跑开销。

FlakeFlagger使用机器学习预测哪些测试可能是Flaky的,无需实际运行。它通过分析测试代码的特征(如异步调用、时间依赖、随机数使用等)来做出判断。

生产环境的实践

许多CI平台已经内置了Flaky测试处理功能。CircleCI的"Test Insights"仪表板可以可视化展示哪些测试是不稳定的;GitHub Actions可以通过重试机制自动处理Flaky测试;Jenkins的Flaky Test Handler插件可以聚合分析Flaky测试的统计信息。

治理:隔离、修复还是删除?

面对Flaky测试,团队有四种基本策略。选择哪一种,取决于具体情况和资源约束。

隔离:第一道防线

当发现Flaky测试时,最紧急的措施是将其从主测试流程中隔离出来,放入一个单独的"隔离区"。这样可以防止它干扰正常的CI反馈。

Martin Fowler建议隔离区应该有明确的容量限制(比如不超过8个测试)和时间限制(比如不超过一周)。这迫使团队及时处理,而不是让隔离区变成被遗忘的垃圾场。

Google的工具更进一步:当检测到一个测试的Flaky比例超过阈值时,系统会自动将其隔离,并创建一个工单供开发者修复。这保证了Flaky测试不会无限期地污染CI环境。

修复:追根溯源

修复Flaky测试需要首先确定根本原因。微软开发的RootFinder工具可以帮助定位Flaky测试的成因——它通过分析测试运行日志,识别出在通过和失败运行中表现不同的因素。

对于异步等待问题,修复方案是替换固定延迟为智能等待。对于顺序依赖问题,需要确保每个测试都从干净的状态开始,或者在测试后正确清理状态。对于并发问题,可能需要添加适当的同步机制或重新设计测试以避免竞态。

删除:艰难但有时必要的选择

不是所有测试都值得修复。如果一个Flaky测试:

  • 覆盖的功能已经不再重要
  • 修复成本远高于价值
  • 测试本身设计就有根本性缺陷

那么删除它可能是正确的选择。研究表明,约有10%的修复Flaky测试的提交,最终选择了删除测试

系统性预防

比修复更重要的是预防。以下实践可以显著减少Flaky测试的引入:

测试隔离:每个测试都应该独立运行,不依赖其他测试留下的状态。使用内存数据库、事务回滚或容器化环境来确保干净的初始状态。

避免外部依赖:对网络服务使用模拟(Mock)或存根(Stub)。集成契约测试可以验证模拟与真实服务的行为一致性,但不需要在每次运行时访问真实服务。

控制时间和随机性:将系统时钟包装为可注入的依赖,测试中可以设置为固定值。对于随机数,总是使用固定种子。

明确定义等待条件:永远不要使用固定延迟等待异步操作。使用回调、Promise或条件轮询。

进化中的战场

Flaky测试不是新问题,但随着CI/CD的普及和测试自动化程度的提高,它的影响被放大了。当代码每天被部署数十次时,每一次Flaky测试的失败都会产生涟漪效应。

研究仍在继续。2023年,康奈尔大学的研究团队提出了FASER框架,专门处理机器学习测试中的非确定性问题。更多的工具正在涌现,试图在测试运行之前就预测其可能的Flaky行为。

但在技术解决方案之外,更重要的是心态的转变。Flaky测试不是一个可以彻底消灭的敌人,而是一个需要持续管理的风险。正如Facebook工程团队所言:所有测试都应默认被视为可能Flaky的——这是一种防御性思维,它提醒我们始终保持警惕,及时检测和处理不稳定行为。

回到凌晨三点的那个场景。当你重跑测试,它通过了,你可以暂时松一口气。但第二天,请记住:那个测试还在你的代码库里潜伏着,等待着下一次随机的失败。找到它,理解它,修复它——或者删除它。因为每一个被忽视的Flaky测试,都在悄悄蚕食着团队对整个测试体系的信任。

参考资料

  1. Parry, O., Kapfhammer, G. M., Hilton, M., & McMinn, P. (2021). A Survey of Flaky Tests. ACM Transactions on Software Engineering and Methodology, 31(1), Article 17.

  2. Luo, Q., Hariri, F., Eloussi, L., & Marinov, D. (2014). An Empirical Analysis of Flaky Tests. Proceedings of the 22nd ACM SIGSOFT International Symposium on Foundations of Software Engineering, 643-653.

  3. Lam, W., Winter, S., Wei, A., Xie, T., & Marinov, D. (2020). A Study on the Lifecycle of Flaky Tests. IEEE Transactions on Software Engineering, 48(12), 4836-4852.

  4. Micco, J. (2016). Flaky Tests at Google and How We Mitigate Them. Google Testing Blog.

  5. Fowler, M. (2011). Eradicating Non-Determinism in Tests. martinfowler.com.

  6. Rasheed, S., Tahir, A., Dietrich, J., Hashemi, N., & Zhang, L. (2023). Test Flakiness’ Causes, Detection, Impact and Responses: A Multivocal Review. Journal of Systems and Software, 205, 111835.

  7. Eck, M., Palomba, F., Castellanos, J. L., & Bacchelli, A. (2019). Understanding Flaky Tests: The Developer’s Perspective. Proceedings of the 2019 27th ACM Joint Meeting on European Software Engineering Conference and Symposium on the Foundations of Software Engineering, 830-840.

  8. Dutta, S., Chen, J., & Lahiri, S. K. (2022). Detecting Flaky Tests in Probabilistic and Machine Learning Applications. Proceedings of the 37th IEEE/ACM International Conference on Automated Software Engineering, 1-13.