减少 CI 回归测试套件规模的虚假承诺
为了提高速度和加快反馈,是否应该减少在持续集成(CI)中定期运行的单元测试和回归测试的数量?关于大规模缩减以及对测试套件做优先级排序的好处,业界已经争论了许多年。很容易就能找到一些构建服务器,以及提供咨询服务的公司,它们提出了各种各样的策略,旨在大幅减少每次构建时运行的测试的数量。据称,其主要好处是缩短开发人员等待测试结果的时间,并提高 CI 实验室的资源利用率。
作为 DevOps 团队的一名测试架构师,我认为这个想法在理论上非常有吸引力,但在实践中却常常令人失望。就单元测试而言,你可以根据依赖项追踪等功能,有效地筛选出一小部分测试用例,使它们仅针对特定的提交运行。但对于大多数规模适中的公司来说,如果单元测试的周转时间是主要问题,那么很可能是在单元测试的编写方式、实现代码的编写方式或依赖项隔离等方面存在着根本性的问题。
当你转向更高层次的集成测试和端到端测试时,便不再有简单可靠的方法可以用来缩减测试集了。举例来说,使用词汇相似度来筛除表面看来冗余的端到端测试,你能够更快地发现一些显而易见的缺陷;但是,如果为了缩小测试结果样本量而在多数构建中策略性地省略大量的测试,就有可能导致一些隐蔽的缺陷所发出的微弱信号无法被察觉。这种风险不容忽视,因为并非所有缺陷的影响程度都相同,而那些难以察觉的缺陷最容易混入软件的发布版本中。因此,这些缺陷最可能造成巨大的时间损失,并带来经济和声誉上的损害。
有效地处理大型 CI 测试套件
为了移除过时或冗余的测试,你应该持续优化持续集成(CI)回归测试套件。不过,有一套规模庞大且兼具高代码覆盖率和高功能覆盖率的测试套件,是一种优势,而非劣势。接下来,我将论证,定期运行大型回归测试集所面临的两大主要问题(反馈缓慢和 CI 实验室容量过载)是可以克服的。通过深入探讨,我们将看到,这些问题可以通过改进测试架构来解决。
首先,我将概要地介绍一种能够有效解决当前问题(如何从庞大的回归测试套件输出的海量结果中找出关键关注点)的方案。然后,我将探讨改进测试架构的适当措施。我会介绍一种随机性方法,实际上,这种方法依赖于持续集成(CI)回归测试集某种程度的冗余。这种方法虽不能保证每次都能捕获所有缺陷,但能为你提供最佳保障,确保不会遗漏 CI 回归测试套件运行过程中出现的任何细微的缺陷迹象。
驯服“杀虫剂悖论”
在理想情况下,我们的回归测试应该总是能“大声疾呼”,准确清晰地指出问题所在。然而,这种清晰度往往会受到所谓“杀虫剂悖论”的阻碍。这个术语指的是一种两难境地:随着测试成功地将缺陷排除在外,现有测试套件发现新缺陷的能力就会减弱。这是因为它们原本能够捕获的大部分缺陷,已经被人发现或预防了。这就导致一些仅在特定条件下才会显现的缺陷无法被发现,如偶发性的、与时间相关的非确定性缺陷。这些条件可能包括网络流量拥堵、CPU 负载过高、环境温度升高,以及 CI 实验室中的其他环境因素。鉴于这类隐蔽的缺陷难以被察觉,更容易混入软件的发布版本中,因此它们往往最为危险。要检测这些缺陷,我们需要对测试结果进行纵向的时间序列分析。
最难以察觉(也往往最严重)的错误是间歇性的,也就是说,它们是与时间相关的,不会持续出现,通常会涉及竞争条件。如果你有办法分析低层次的故障模式,那么即使存在“杀虫剂悖论”,你也能发现这些难以察觉的错误。通常,这类缺陷由更高层次的集成测试和端到端测试捕获,而这些测试往往会受到众多随机因素的影响。因此,其运行结果的确定性远不如单元测试。要利用这种变化性所蕴含的潜力并使该方法取得成功,关键在于不要只关注昨晚的静态测试结果,而是将焦点转向测试结果的时间序列,观察其随时间变化的趋势。
一种随机解决方案:趋势分析
我构建了一个实现了这种方法的系统。在每次收到新的端到端测试结果时,它会分析每个测试三十天来的变化趋势。该系统采用了一个受函数式编程启发的 Python 框架,通过提供文本名称修饰符来识别不同类型的显著变化趋势(如 flaky、stabilize_failing 、regression 等)。除了这些文本标签外,该系统还会为每个测试生成一条时间跨度三十天的红绿趋势带,用来表示每次测试的通过与失败模式。随后,我在团队仪表盘上创建了一个特殊的视图,针对所有存在趋势问题的测试展示这两项内容:

图 1:仪表盘“故障”视图
因此,在这个可视化界面(即我们的“故障视图”)中,我们会筛选并重点关注那些从趋势上看可能存在回归问题的测试。在将该视图整合到仪表盘后,我的团队意识到,没有必要调查每一个前一晚运行失败的测试。如果我们只专注于“故障视图”中显示出不良趋势的测试,那么在查找回归问题方面会高效许多。
实现这种可视化的原因在于:如果你按照发现它们的顺序逐一排查失败的测试,那么你会发现一些你认为需要修复和改进的问题。一旦开始处理某项任务,你就想把它完成。这就是任务处理的心理机制。但问题在于,如果没有一种系统化的方法来判断与管理 CI 回归测试集相关任务的相对重要性(而上述解决方案正是为此提供的),你就不可避免地会将大部分精力投入到影响比较小的任务上,而忽视了最重要的任务。这一解决方案重点关注当前的关键事项,使你和你的 DevOps 团队能够有效地处理规模庞大的 CI 回归测试集。
这种方法有别于随机缩减技术,后者是通过过滤掉那些明显冗余的测试来达到使测试集更易于管理的目的。我们保留了完整的测试集,但通过随机缩减将关注范围聚焦于那些最有可能存在回归问题或需要尽快修复的基础设施问题的失败案例。
无门控测试的优势
更高层次的测试,尤其是端到端测试,天生就容易出现不稳定的情况,因为它们会受到许多外部因素的影响。因此我们发现,把这类测试设为非阻塞测试更为妥当,这样即使测试失败,也不会导致构建失败。我之前概述的趋势追踪方法在识别回归问题方面已经足够精准,因此,我们无需通过构建失败这种强硬的手段来防止回归问题进入软件的发布版本。在我们的故障视图可视化界面中,这些问题始终清晰地呈现在眼前,使我们有充足的机会发现并解决它们。使用该工具有一个强有力的理由,它将关注点从“通过测试”转移到了“收集软件正确性信息”上。这种转变甚至影响了我们编写测试的方式,使我们更倾向于编写旨在捕获回归的测试,而非仅仅为了通过测试而优化测试。
从逃逸缺陷中吸取教训
我们是一家大型组织,测试覆盖面广且深入,主要在处于持续开发中的“主”分支上进行测试。因此,我们几乎每天都能发现真实的问题。回归故障可能表现如下(这是我在撰写本文时发现的):在覆盖我们产品线中某类设备的端到端测试中,原本出现的一些低级别不稳定性,突然被回归故障的明确特征所取代:

图 2:回归特征
从这个视图可以看出,回归问题显而易见。自从采用这种方法以来,我们仅发现过一例:有一个隐蔽的缺陷,虽然已经在“故障视图”中被标记,并且经多人审查,但最终还是逐渐恶化,变成了客户缺陷。该缺陷极其隐蔽,即便已在“故障视图”中被如实标记,而且有多个人查看了屏幕截图和日志文件,却还是被遗漏了。但上述方法的关键优势在于,即便在极少数失效的情况下,我们也能完整记录“故障”状态的演变过程,从而可以在事后分析中对每日已知情况进行全面的追溯。以下是该漏洞最终混入已发布软件的报告,从中可以看到这一点:

图 3: 事后分析
这种方法提供了从漏网的缺陷中汲取经验所需的一切要素,使我们可以改进流程和工具,确保此类疏漏不再重演。
利用冗余:多上下文模式匹配
我一直致力于发现单个测试随时间推移而逐渐显现的异常模式。然而,如果你在不同的场景下、从不同的角度,或者使用相同的代码通过不同的产品来测试同一项功能,那么就会出现一种有趣的现象:经过一夜的测试,你就可以发现指向反复出现的问题和潜在回归的模式,而无需等待数个夜晚才能观察到趋势的形成。
对于这种情况,我们可以使用配对可视化。举例来说,对于昨晚失败的测试和前晚失败的测试,我们可以分别把它们的顶级功能域标签做成标签云,并将两者进行比较。标签云中每个标签的相对大小代表其比例,反映了各功能领域中测试失败的数量:

图 4:多上下文模式匹配
可以看到,在产品的夜间构建中,与 Zoom 相关的功能发生了很大的变化。我们发现,上方云图中的 @zoom 标签(最近一次夜间运行),与下方云图中的相比尺寸变大了。随后,我们可以点击 @zoom 标签对仪表盘上的结果进行筛选,并立即查看失败的测试用例列表,从而判断这是回归问题,还是仅需立即修正的测试基础设施问题。
发现这类模式的关键在于测试集具有足够的冗余度。如果每个功能领域都仅进行最低限度的测试且毫无重叠,那么哪怕只有一个测试不稳定也会导致结果失真,而我们的团队就会浪费时间去排查误报。如果某个领域的测试失败模式重复出现的频率足够(如在使用相同代码的几条不同的产品线上),那么我们就能更有把握地认为,这种失败模式源于真正的回归问题或测试基础设施问题。
与基于时间轴的“故障视图”方法一样,配对可视化方法也具有极佳的可扩展性,这可以帮助我们的团队应对管理大型测试集时所面临的认知负荷。现在,剩下的就是解决大型 CI 回归测试集带来的其他难题。正如我之前提到的,这些难题包括开发人员等待结果的周转时间过长,以及占用过多的 CI 实验室资源。
考虑速度、可扩展性及有效识别回归的架构设计
你可以通过并行测试以及持续报告结果(而非等到测试运行结束时才报告)来缓解反馈周期长的问题。即使你在真实的嵌入式目标设备上运行了大量的端到端测试,只要构建一个规模足够大的 CI 实验室,并且设计出的测试任务可以在众多不同的嵌入式设备上大规模并行运行,便依然能够实现快速的反馈周期。采用这种方法时,要确保每个任务都能持续地报告测试结果。我们将测试结果发布到了 Elasticsearch,这使得我们几乎可以实时地从仪表盘上观察重要趋势的演变过程。
即使你的团队不从事嵌入式软件开发,通过模拟数据库、文件系统和传感器数据等繁重的依赖项,也能加快测试速度,提升持续集成实验室的处理能力。在将来自众多物理传感器的数据输入到复杂的识别算法进行测试时,我对此深有体会。若要在实际的嵌入式系统上测试算法处理的所有边界情况,那就太疯狂了;相比之下,使用单元测试框架并模拟来自传感器的所有可能的输入,仅测试算法在每种情况下的决策过程,这种做法要简单得多、可靠得多,最重要的是,速度也快得多。
如果你需要在大型设备上测试嵌入式解决方案,但受限于空间和成本,无法在实验室中大量部署这类设备,那么你仍然有很好的并行化方案可供选择。你可以采用智能硬件在环(HIL)方法,将大部分测试工作转移到测试实验室机架中部署的系统小型子组件上进行。这样,你只需将大型设备用于执行一套执行时间更短的最高级别的集成测试即可。
小结
由于我在工作中习惯采用分析性和战略性的方法,所以一开始我认为,减少每次构建时运行的测试的数量这一提议很有吸引力。对于单元测试而言,测试和软件行为都具有极强的确定性,我对这一想法并无异议。不过,如果单元测试套件实现得当,并且测试对象是依赖关系极少或完全没有依赖关系的小单元,那么它们的运行速度将非常快。在这种情况下,在每次构建时优化掉部分测试所产生的收益将极其有限。当尝试将这种方法应用于更高层次的集成测试和端到端测试时,我就更加怀疑其效果了。
到了这一步,你将面对被测系统中复杂的多层结构,其中隐藏着绝大多数难以察觉的缺陷。正如我在本文中所介绍的,能够用于识别此类缺陷的潜在模式往往需要随着时间的推移才会显现,而且可能只有拥有专用模式发现工具的人才能察觉。在大多数构建中,如果通过策略性地省略许多复杂的测试来缩小测试结果的样本量,就会导致这些难以察觉的缺陷所发出的微弱信号被漏掉。
采用我在本文中介绍的这种随机测试方法,你发现那些极有可能逃逸并进入软件发布版本的漏洞的可能性将大大提高。这一点非常重要,因为尽管这些漏洞可能很隐蔽,但当系统的用户成千上万时,那就会产生巨大的负面影响。此外,这些隐蔽的漏洞往往会让用户感到更加沮丧,因为在处理这类漏洞时,支持团队往往会将用户的报告视为无法重现而不予理会。
我所提出的这种方法能够有效地解决这一问题,即在大量的隐蔽缺陷扩散到软件发布版本之前,更有效地捕获它们。此外,这种方法还能持续不断地显示最重要的测试失败情况,使 DevOps 团队可以进行重点处理和排查,从而能够有效地管理庞大的 CI 回归测试集。
归根结底,选择权在你手中。如果你优先考虑的是尽快完成测试集,那么你可以继续专注于此。或者,考虑到本文所述的优化措施,你愿意投入一定的时间,采用一种能最大程度地确保测试发现的所有缺陷都不会进入软件发布版本的方法。
声明:本文为 InfoQ 翻译,未经许可禁止转载。
原文链接:https://www.infoq.com/articles/alternative-reduce-test-suite-size/





