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

不稳定测试简介

单元测试是持续集成(CI)系统的基石。在软件工程师新实现的代码合并到已有代码之前,它会对其中的错误和已有代码中的回归给出警告。

它提升了软件的可靠性,还提高了开发人员的整体生产力,因为他们在软件开发生命周期的早期就能发现错误。因此,构建稳定可靠的测试系统通常是软件开发组织的关键要求。

不幸的是,根据定义,不稳定(flaky)单元测试是与这一要求相悖的。如果单元测试在任意两次执行中返回不同的结果(通过或失败),而没有对源代码进行任何底层更改,则该单元测试被认为是不稳定的。

测试代码或正在测试的代码中的程序级不确定性(例如线程顺序和其他并发问题)可能会导致不稳定测试。或者,它的成因可能是测试环境(例如执行它的机器、同时执行的测试集等)的可变性。前者需要修复代码,后者则要找出导致不确定性的原因,并解决它们以消除不稳定因素。代码模式和基础设施的测试必须尽量减少出现不稳定测试的可能性。

不稳定测试会在多个维度上影响开发人员的生产力。首先,当测试由于外来原因失败时,测试人员必须调查问题成因;如果失败的可重复性是不确定的,这就可能非常耗时。在许多情况下,在本地重现故障可能是不切实际的,因为故障需要特定的测试配置和执行环境才能复现。

其次,如果无法确定不稳定性的根本原因,就必须在 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 方法将失败,并显示“端口已在使用中”绑定异常。

一般来说,重现导致不稳定情况的原因需要开发人员了解不稳定情况的位置(例如,在上面的示例中是硬编码的端口号)。这是一个循环问题,因为不稳定性可能有多种表现形式,并且类似的直接“原因”(例如 Java 异常或测试失败类型)可能对应非常不同的根本原因,这些原因出现在测试执行的早期,如下图所示 1。

此外,要重现异常堆栈跟踪,还要适当设置环境(例如,连接到同一端口的测试也应并发执行)。

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

为了检测不稳定测试,我们使用了测试分析器捕获的以下数据:

  1. 测试用例元数据:

  2. 测试(Test)名称

  3. 测试套件(TestSuite)名称

  4. 标识项目中构建规则的目标(Target)名称。

  5. 测试结果

  6. 运行测试的时间

  7. 连续成功运行次数

  8. 每次失败测试运行的堆栈跟踪(如果有)

  9. 测试用例的当前状态(稳定或不稳定)

我们使用这些信息对主分支上的所有测试进行分类,连续 100 次成功运行的测试是稳定的,其余为不稳定。

基于此,一个不稳定测试禁用作业会定期禁止不稳定测试,防止它们影响 CI 相关的结果。换句话说,在为新的代码更改运行测试时,会忽略与不稳定测试相关的失败。下面的图 3 说明了这种情况:

图 4:通过端口冲突检测工具链确定对可用端口的测试灵敏度

上面的图 4 描述了这个过程。独立运行时,某个不稳定测试可能会成功。安全管理器用于侦听测试访问的端口,并将该信息作为输入提供给 Port Claimer 程序。

当测试与保留已识别端口的 Port Claimer 一起执行时,如果测试失败,则会生成复现器命令。该命令可以声明已识别的端口并在这些条件下运行测试,从而帮助开发人员在本地确定地重现问题。

最后,我们还在节点增加额外负载的条件下运行测试。我们会生成多个进程(类似于stress命令)来实现这一点,并确保测试在这些高 CPU 负载条件下成功。

如果测试具有内部编码的时序依赖性(另一个常见的不稳定来源),则可以立即重现这种不稳定性。我们使用对应的数据输出一个重现命令,开发人员可以使用该命令在所需的压力负载下运行测试,来在本地分类问题。

把不稳定测试的修复众包出去

上述对不稳定测试的分类有助于解决与基础设施相关的故障,和可以集中处理的其他类型的故障。为了扩展修复不稳定测试的过程,我们向全优步的工程师发起了众包修复,并在多个级别上执行这一行动:在针对所有向单体存储库提交代码的开发人员的“修复周”活动中推动修复众包,并对接不稳定测试比例最高的团队。

我们针对部署的努力,以及重现不稳定失败的基础设施和工具支持,在短时间内显著降低了不稳定测试的总体百分比。复现器基础设施还让开发人员可以定期轻松地对较新的不稳定测试进行分类和修复。

静态检查

在合并到单体存储库后消灭已有的不稳定测试只是任务的一部分。为了提供长期稳定的 CI,我们还希望能先降低引入新的不稳定测试的速度。

我们希望这样做不至于要在每次代码更改的全套测试上运行多个动态复现器。全面的动态分析方法需要我们在每次代码更改时运行许多测试,以寻找潜在的冲突测试用例。

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

在优步,我们主要使用谷歌的Error Prone框架对 Java 代码进行构建时静态分析(另见:NullAwayPiranha)。我们减少测试不稳定性的努力是全方位的,作为这种努力中的一部分,我们已经开始实现简单的 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));
  …

在这里,开发人员创建了一个倒计时为 1 的 latch 对象。然后将该对象传递给某个后台线程 t,该线程可能会运行某个任务(此处抽象为 CountDownRunnable 对象),任务调用 latch.countDown()来宣告完成。

启动此线程后,测试代码调用 latch.await,超时为 100 毫秒。如果任务在 100 毫秒内完成,则此方法将返回 true 并且 JUnit 断言调用将成功,继续测试用例的其余部分。

但是,如果任务未能在 100 毫秒内就绪,则测试将因断言失败而失败。当测试单独运行时,100 毫秒的超时很可能总是足够完成操作,但在高 CPU 压力下超时就太短了。

正因如此,我们采取了称得上固执(opinionated)的步骤,不鼓励在测试代码中使用 latch.await(…)API 调用的有界版本,并用无界 await()调用替换它们。当然,无界 await 也有自己的问题,会导致潜在的进程挂起。

但是,由于我们仅在测试代码 2 上强制执行此约定,因此我们可以依靠精心选择的全局单元测试超时限制来检测任何可能无限期运行的单元测试。我们认为,这比尝试以某种方式静态估计单元测试中特定操作的“合适”超时值更可取。

除了 Java 的 CountDownLatch,我们的检查还处理其他由于依赖挂钟时间而引入不稳定性的 API。附带说明一下,如果我们的检查器判断操作总是会超时,我们明确允许使用有界 await 的测试代码,因为这不是压力下的不稳定来源。

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

开发人员提交代码更改,这些更改会通过 CI,后者识别任何编译或测试失败。如果在 CI 上构建成功,开发人员使用称为SubmitQueue(SQ)的自制内部工具合并他们的更改。

不稳定测试会导致 CI​​和 SQ 作业失败,而这些作业以前无法由开发人员操作,结果对开发速度以及他们部署和发布新特性的能力产生负面影响。

上述这些步骤和工具减少了开发人员运行 CI/SQ 作业时遇到的失败,还通过避免多次重新运行和减少 CI 运行时间来减少了 CI 资源的使用量。由于不稳定测试的数量显著减少(大约 85%),我们接下来能重新运行 CI 期间失败的测试用例,以确定它们是否可能是不稳定的;如果是这样,无论如何都要通过构建(而不必等待 TAS 移除不稳定测试)。这种方法消除了不稳定测试对 CI 和 SQ 的所有影响,从而大大提高了软件可靠性和开发人员生产力。

未来发展方向

除了上述工作外,我们正在探索更多减少不稳定测试的机会,包括:

  1. 构建更通用的系统来检测不稳定性的根本原因,包括并发错误和测试用例之间的通行交互。

  2. 构建工具,将可重现的不稳定测试失败分配给具有相关所有权和域区域上下文的工程师。

  3. 扩展我们的动态复现器和静态检查器,以处理其他不稳定来源(例如,我们正在改进静态检查,以防止硬编码端口号出现在测试中,包括那些由库默认值和配置文件带来的端口号)。

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

  5. 扩展我们的工具链以处理使用其他主要语言(如 Go)的优步单体存储库中的不稳定单元测试。

致谢

我们要感谢这个项目的其他贡献者(按字母顺序):我们要感谢来自阿姆斯特丹和美国的开发平台团队的几位贡献者,他们为这个项目做出了贡献,包括 Maciej Baksza、Raj Barik、Zsombor Erdody-Nagy、Edgar Fernandes、Han Liu、Yibo Liu、Thales Machado、Naveen Narayanan、Tho Nguyen、Donald Pinckney、Simon Soriano、Viral Sangani、Anda Xu。

原文链接:

https://eng.uber.com/handling-flaky-tests-java/

本文文字及图片出自 InfoQ

本文文字及图片出自

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注