Uber:Java中的不稳定单元测试处理

Flaky Tests介绍

单元测试是任何持续集成(CI)系统的基石。在合并之前,它向软件工程师警告新实施的代码的错误和现有代码的回归。这在软件开发生命周期的早期就能发现bug,从而确保提高软件的可靠性,也提高了整个开发人员的生产力。因此,建立一个稳定和可靠的测试系统往往是软件开发组织的一个关键要求。

不幸的是,顾名思义,Flaky Tests削弱了稳定性和可靠性。

Flaky Tests是一种不可靠、不稳定的测试现象:即在同样的软件代码和配置环境下,得不到确定(有时成功、有时失败)的测试结果。

如果一个单元测试在任何两次执行中返回不同的结果(通过或失败),并且没有对源代码进行任何底层修改,那么它就被认为是不稳定的。

一个Flaky Test可能是由于测试代码或被测代码中的程序级不确定性(例如,线程排序和其他并发性问题)而引发的。或者,它可能由于测试环境的可变性而引发(例如,执行测试的机器,同时执行的测试集,等等)。

前者需要修复代码,而后者则需要确定导致不确定性的原因,并解决这些原因以消除flakiness的现象。对代码模式和基础设施的测试,必须着眼于减少出现Flaky tests的可能性。

Flaky tests会在多个方面影响开发者的生产力。首先,当一个测试由于不相干的原因而失败时,必须调查根本的问题,这可能很耗时,因为失败是不确定的可重复性。在许多情况下,在本地重现故障可能是不切实际的,因为它需要特定的测试配置和执行环境来表现出错误。其次,如果不能确定故障的根本原因,那么在CI期间必须充分地重试测试,以便观察到测试的成功运行,并且要合并伴随的代码修改。这个过程的两个方面都浪费了关键的开发时间,因此有必要建立基础设施支持来处理单元测试故障的问题。

我们用一个简单的、说明性的例子来进一步阐述这个问题:

private static int REDIS_PORT = 6380;

…

@Before

public void setUp() throws IOException, TException {

    MockitAnnotations.initMocks(this);

    …    

    server = RedisServer.newRedisServer(REDIS_PORT);}

在单元测试运行前执行的setUp方法中,通过REDIS_PORT定义的端口6380连接到RedisServer。当相关的单元测试在开发者机器上本地运行时,在没有错误的情况下,测试将成功完成。然而,当这段代码被推送到CI并在CI环境中运行相关测试时,只有当setUp方法运行时,6380端口在环境中可用,测试才会成功。如果CI环境中还有其他同时执行的单元测试已经在监听同一个端口,那么例子中的setUp方法将以 "端口已被使用 "的绑定异常而失败。

一般来说,重现flakiness的原因需要开发人员了解flakiness的位置(例如,在上面的例子中硬编码端口号)。这是一个周期性的问题,因为可能有很多flakiness的表现形式,类似的直接 “原因”(如Java异常或测试失败类型)可能对应于测试执行早期非常不同的根本原因,如下图1所示。此外,为了重现异常堆栈跟踪,应该适当地设置环境(例如,连接到同一端口的测试也应该同步执行)。

Uber:Java中的不稳定单元测试处理_第1张图片

图1:flakiness的根本原因和测试失败时的明显症状

在Uber,当我们把各个软件库合并到一个monolithic repository(单一代码库),以利于在Monorepo(monolithic repository的简称)上发挥开发相关的集中化优势时,由于Flaky tests而造成的痛点进一步加剧。这种集中化的优势包括集中化团队管理依赖关系、测试基础架构、构建系统、静态分析工具等的能力,可以降低总体成本,并确保整个组织的一致性。

然而,迁移到monorepo的过程中,flaky tests影响了开发人员的生产力。由于执行环境更加复杂,同时运行的测试数量也更多,因此在单个存储库中不一定有问题的测试在单版本中也变得不稳定。由于这些测试最初并不是为了在monorepo规模下运行而设计的,当把它们迁移到monorepo时,产生或暴露出明显的flakiness并不令人惊讶。

在这篇文章的其余部分,我们将解释我们的方法,以减轻flaky tests的影响。我们将讨论测试分析器服务(Test Analyzer Service)的设计,该服务被用来管理单元测试的状态,并禁用flaky tests。随后,我们将解释我们在分类不同的flakiness来源和建立程序分析工具(自动重现器以及静态检查器)以帮助重现flaky故障和防止在monorepo中添加新的flaky tests方面的努力。最后,我们将分享我们从这个过程中学到的东西。

使用测试分析器(Test Analyzer)管理Flaky Tests

在解决flaky tests问题时,我们的直接目标是区分monorepo中的稳定和易变测试。在高水平上,这可以通过定期执行monorepo主分支中的所有单元测试,并记录与每个测试相关的最后k次运行历史来实现。由于这些测试已经是主分支的一部分,它们被期望无条件地成功。如果一个测试在最后的k次运行中哪怕有一次失败,它就被归类为不稳定,并被单独处理。

为此,我们建立了一个通用的测试分析器工具,帮助分析和可视化单元测试报告,以满足Uber的大规模测试需求。该工具的核心被称为测试分析器服务(即Test Analyzer Service ,简称TAS),它消耗与执行测试相关的数据,并对其进行处理,以生成可由单个开发人员进行可视化和分析的数据。该分析捕获了大量的测试元数据,包括执行测试的时间、测试执行的频率、最后成功的时间等。这项服务在Uber为特定语言的monorepos运行,因此在它们之间存储了数十万个单元测试的处理信息。每个monorepo都有多个CI管道,定期执行测试并将测试报告送入TAS。最近的数据存储在本地数据库中,而长期的结果则存储在数据仓库中进行历史分析。我们利用TAS,建立了一个自定义的管道,其目标是运行monorepo主分支的所有单元测试,以帮助识别和分离flaky tests。

下面的架构图显示了工作流程,从运行测试的CI作业开始,通过测试处理程序CLI将结果送入TAS,其结果被存储在本地DB和数据仓库。TAS通过API将这些数据暴露出来,以便在测试分析器的用户界面上进行可视化,也可用于进一步分析。代码审查工具已经集成到测试分析器工具中,以实现结果的可视化,并更好地理解测试失败。

Uber:Java中的不稳定单元测试处理_第2张图片

图2:测试分析器服务和相关系统的架构

为了进行flaky test检测,我们使用测试分析器捕获的以下数据:

1.测试案例元数据:

测试名称

测试套件名称

识别项目中构建规则的目标名称

测试结果

运行测试的时间

2.连续成功运行的次数 ;

3.每个失败的测试运行的堆栈跟踪,如果有的话;

4.测试案例的当前状态(稳定或不稳定)。

我们使用这些信息将主分支上所有连续成功运行100次的测试归类为稳定测试,其余测试归类为不稳定测试。基于此,flaky test禁用器作业会定期禁用flaky test,使其不会影响与CI关联的结果。换句话说,在运行新代码修改的测试时,与flaky test相关的失败被忽略。下面的图3说明了这种情况。

Uber:Java中的不稳定单元测试处理_第3张图片

图3:通过测试分析器服务进行故障测试分类

由于flaky tests的结果在代码变更合并时被忽略,这就减少了他们对合并到monorepo的开发人员的影响。当然,这也会影响到可靠性,因为在测试被归类为flaky的期间,flaky test 所测试的功能是未被测试的。这是我们为了保持开发引擎运行而特意做出的权衡。当开发人员修复了flaky tests,并且在自定义CI管道上连续运行100次后,这些测试被重新归类为稳定的测试,这个问题在一定程度上得到了改善。

虽然区分不稳定测试和稳定测试是处理这个问题的必要步骤,但这并不能全面解决这个问题,因为:

a.测试被忽略了,这影响了软件的可靠性,并最终影响了开发人员的生产力,因为要追寻由此产生的错误。

b.开发人员没有基础设施支持来分流和修复flaky tests,这导致相当一部分被归类为flaky tests没有被修复,因为开发人员没有很好的方法来重现(从而调试)测试失败。

减少Flaky Tests

我们以分层的方式来解决减少测试失误的问题。

这有助于减少flaky tests的总数,但无法扩展,因为这个过程不容易处理flaky test的长尾症状和根本原因。此外,集中的开发者体验团队没有资源来处理所有有问题的测试案例的分流,也没有意识到每个测试打算验证的团队特定的背景(因此是解决他们特定的闪失问题的正确方法)。

因此,为了使任何开发人员能够分流不稳定故障,我们建立了动态再现器工具,可以用来在本地再现故障。此外,为了减少monorepo中flaky tests的增长,我们建立了静态检查器,以防止已知flakiness来源的新测试被引入monorepo中。在本节的其余部分,我们将详细讨论这些策略。

各种类型的Flakiness(不稳定性)

一个flaky test可以独立表现出不稳定的行为,也可以因为外部因素而不稳定,比如运行时环境/基础设施,或者依赖的库/框架。为了理解这一点,我们通过分析堆栈痕迹对失败的原因进行分类。从最初的数据中,我们发现大部分的flaky tests都是由于外部因素造成的,例如:

1.高度并行的运行环境:在转移到monorepo之前,每个subrepo都会按顺序运行其测试。Monorepo测试是并行运行的,这可能导致CPU/内存的争夺,从而导致不稳定的故障。

2.嵌入式数据库/服务器:许多测试使用嵌入式数据库(如cassandra、mariadb、redis),有自己的逻辑来启动/停止和清理其状态。这些自定义的实现往往有细微的bug,如果嵌入式服务器启动失败,就会导致一个坏的状态。随后,使用嵌入式数据库的其他测试会在并行运行环境中失败。

3.端口碰撞:

a.嵌入式数据库/服务器经常有硬编码的端口,这使得测试在CI上并行运行时不可靠。

b.Spark(编者注:Spark是一个通用的大数据分析引擎,具有高性能、易用和普遍性等特点。)默认启动了一个UI服务器,这在测试过程中往往没有被禁用。UI服务器使用一个固定的端口,这就导致了端口绑定失败,从而导致两个涉及Spark的测试一起运行时的不稳定。

由于大多数flaky tests是由外部因素造成的,我们开始以集中的方式处理它们:

1.我们迁移了使用嵌入式数据库访问容器化数据库的测试,而不是通过利用testcontainers库。

a.这有助于实现解耦,同时稳定了测试数据库的启动和停止过程。

b.数据库现在运行在自己的容器上,从而解决了不可用的端口问题,因为每个容器都被分配了一个随机的可用端口。

c.Testcontainers库被用于MariaDB、Cassandra、Redis、Elasticsearch和Kafka。

2.对于Spark测试,Spark UI在测试期间被禁用,因为我们的测试都不依赖于Spark UI的显示,这就消除了flakiness。

在修复这些基础设施导致的flakiness的同时,我们也同时着手建立再现器工具来处理仍然不可避免地发生的flakiness。

重现Flaky Tests

开发人员在处理flaky tests时面临的一个障碍是他们无法调查flakiness的根本原因。这主要是由于他们无法可靠地重现这些故障。因此,基于flaky tests的分类和我们自己对其他flaky tests修复的分析,我们建立了动态重现器工具,以便能够重现观察到的flaky test失败。

我们建立了一个系统,开发人员可以输入一个测试的细节,并触发与之相关的自动分析。我们的分析将在各种情况下执行测试,以帮助重现基本问题。具体来说,它将:

1.只运行输入的测试;

2.运行输入测试类中的所有测试;

3.运行测试目标中的所有测试 ;

4.在端口碰撞检测模式下运行测试;

5.重复步骤1-3,同时增加系统的资源负荷。

执行测试的前三类是为了处理测试方法、类或目标中的任何局部问题。例如,在少数情况下,由于适当的测试之间的依赖关系(即单元测试不是真正独立的,而是期望由同一测试类中的其他测试设置的状态),单独运行测试方法,而没有类中的其他测试,可以帮助重现故障。应用这个简单的启发式方法有助于发现非微不足道的flaky tests。

基于我们的分析,我们也注意到有许多被归类为端口碰撞的flaky tests 。我们观察到,检测访问相同端口的测试组合取决于同时调用适当的测试组合。在几十万个测试的集合上应用这种策略,实际上是不可行的。相反,我们设计了一种分析方法,独立执行每个测试,但识别与任何其他可能的测试的端口碰撞的潜在来源。

为此,我们使用Java安全管理器(Java Security Manager)来识别测试所访问的端口集。(编者注:Java Security Manager的网址为https://docs.oracle.com/javase/tutorial/essential/environment/security.html)

一个单独的进程,名为Port Claimer,被生成以绑定和监听确定的端口(在IPv4和IPv6上)。当Port Claimer监听时,测试被重新执行,任何新的被访问的端口集被识别,然后被Port Claimer获得。这种分析被重复几次,以收集测试使用的潜在端口集。如果使用一个恒定的端口,那么测试的其中一次重新执行将失败,因为Port Claimer正在监听先前确定的端口。否则,一个新的端口可以被测试所访问。通过重复这个过程几次,我们可以克服测试对一组恒定端口的使用。如果一个测试的执行失败了,那么我们可以输出一个简单的reproducer命令,它将生成Port Claimer来连接一组确定的端口,然后执行所考虑的flaky test。然后,这可以被开发人员用来在本地分流问题并修复根本原因。

Uber:Java中的不稳定单元测试处理_第4张图片

图4:通过端口碰撞检测工具确定测试对可用端口的敏感性

这个过程在上面的图4中描述了。当独立运行时,一个flaky test可能会成功。安全管理器被用来监听测试所访问的端口,该信息被作为输入提供给Port Claimer进程。当测试与Port Claimer一起执行时,如果测试失败了,就会产生一个reproducer命令。这个命令可以用来帮助开发人员在本地确定地重现问题,方法是要求确定的端口并在这些条件下运行测试。

最后,我们还在节点上的额外负载下运行测试。我们通过生成多个进程(类似于压力命令)来实现这一点,并确保测试在这些高CPU负载条件下成功。如果测试有内部编码的时间依赖性–另一个常见的缺陷来源–那么这种缺陷可以立即被复制。我们使用相应的数据来输出一个重现器命令,开发人员可以通过在所需的压力负载下运行测试,在本地对问题进行分类。

众包对Flaky Tests的修复

上述对flaky tests的分类有助于解决与基础设施相关的故障和其他类型的故障,可以集中处理。为了扩大flaky tests的修复过程,我们从Uber的工程师那里众包修复,并在多个层面上进行:在 "修复周 "活动中推动修复,针对所有提交代码到monorepo的开发人员,并让flaky tests比例最高的特定团队参与。

我们集中部署的努力,加上基础设施和工具的支持,以重现故障,结果在很短的时间内大大减少了flaky tests的总体比例。构建一个重现者的基础设施也确保了较新的flaky tests可以很容易地由开发人员定期进行标记和修复。

静态检查

在合并到monorepo之后,删除现有的flaky tests只是其中的一部分。为了提供一个稳定的CI,我们最好也能减少新的flaky tests被引入的速度。

我们希望做到不必为每次代码更改在全套测试上运行多个动态复制程序而产生额外成本。一个全面的动态分析方法需要我们对每一个代码变化运行许多测试,以寻找潜在的冲突测试用例。

它还需要在不同的flakiness再现器下多次运行测试用例。由于这种开销会在代码审查时对开发人员的工作流程产生不可接受的影响,自然的解决方案是使用某种形式的轻量级静态分析(也称为linting),在新添加或修改的测试中寻找已知的与flakiness有关的模式。

在Uber,我们使用谷歌的Error Prone(https://errorprone.info/)框架,主要用于Java代码的构建时静态分析。作为我们减少测试漏洞的全面努力的一部分,我们已经开始实施简单的Error Prone检查器,以检测已知在我们的CI测试环境中引入漏洞的代码模式。

当一个测试与这些模式相匹配时,在编译过程中会触发一个错误–这发生在本地,也发生在CI–促使开发人员修复(或抑制)这个问题。我们通过分析跟踪监测这些检查的速度,并跟踪个别检查被抑制的速度。

在本节的其余部分,我们将主要关注一个特定的静态检查例子:我们的ForbidTimedWaitInTests检查器。

例如,考虑以下使用Java的CountDownLatch的代码。

final CountDownLatch latch = new CountDownLatch(1);
    Thread t = new Thread(new CountDownRunnable(latch));

    t.start();
    assertTrue(latch.await(100, TimeUnit.MILLISECONDS));

在这里,开发者创建了一个latch对象,倒计时为1。然后这个对象被传递给某个后台线程t,它大概会运行一些任务(在这里被抽象为一个CountDownRunnable对象),通过调用latch.countDown()发出完成信号。

在启动这个线程后,测试代码调用latch.await,有100毫秒的超时。如果任务在100毫秒内完成,那么这个方法将返回true,JUnit断言调用将成功,继续测试案例的其余部分。然而,如果任务未能在100毫秒内准备好,测试将以断言失败告终。很有可能100ms的超时在测试独立运行时总是足以完成操作,但在CPU高压力下超时太短。

正因为如此,我们采取了一个有点主观的步骤,不鼓励在测试代码中使用有界的latch.await(…)API调用,而用无界的await()调用来代替它们。当然,无界的等待有其自身的问题,导致潜在的进程挂起。然而,由于我们只在测试代码2中强制执行这一约定,我们可以依靠精心选择的全局单元测试超时限制来检测任何单元测试,否则会无限期地运行。我们相信,这比试图以某种方式静态估计单元测试中特定操作的 "良好 "超时值要好得多。

除了Java的CountDownLatch,我们的检查还可以处理其他API由于依赖挂钟时间而引入的浮动性。顺便提一下,如果我们的检查器发现操作总是超时,我们明确地允许测试使用有界等待的代码,这不是压力下flakiness的来源。

这些变化对开发人员有什么影响?

开发人员提出代码修改,通过CI来识别任何编译或测试失败,如果在CI上构建成功,开发人员使用一个自定义的内部工具合并他们的修改,称为SubmitQueue(SQ)。Flaky tests导致CI和SQ工作的失败,以前开发人员无法采取行动,对开发速度和他们部署和发布新功能的能力产生了负面影响。

上面提到的各种步骤和工具减少了开发人员运行的CI/SQ作业的失败,还通过避免多次重新运行和减少CI运行时间而导致CI资源使用的减少。随着flaky tests的数量大大减少(约85%),我们进一步能够对CI期间失败的测试用例进行重新运行,以确定它们是否可能是故障的,如果是,则无论如何都要通过构建(无需等待TAS来删除flaky test)。这种方法消除了flaky tests对CI和SQ的所有影响,为软件的可靠性和开发人员的生产力带来了巨大的胜利。

未来的方向

除了上述工作外,我们正在寻找更多的机会来减少flaky tests,包括:

更加通用的系统来检测flakiness的根本原因,包括并发错误和测试用例之间的通用交互。

将可重复的flaky tests失败分配给具有相关所有权和领域背景的个别工程师的工具。

扩展我们的动态重现器和静态检查器,以处理其他漏洞的来源(例如,我们正在进行静态检查,以防止硬编码的端口号出现在测试中,包括那些来自库默认和配置文件的端口号)。

提高运行我们的动态再现器的效率,可能会让它们在代码审查时运行。

扩展我们的工具,以处理其他主要语言特定的Uber单体(如Go)中的单元测试。


资源分享

下方这份完整的软件测试视频学习教程已经上传CSDN官方认证的二维码,朋友们如果需要可以自行免费领取 【保证100%免费】

在这里插入图片描述

在这里插入图片描述

你可能感兴趣的:(自动化测试,软件测试,程序人生,java,单元测试,开发语言,自动化测试,程序人生)