单元测试是持续集成(CI)系统的基石。在软件工程师新实现的代码合并到已有代码之前,它会对其中的错误和已有代码中的回归给出警告。
它提升了软件的可靠性,还提高了开发人员的整体生产力,因为他们在软件开发生命周期的早期就能发现错误。因此,构建稳定可靠的测试系统通常是软件开发组织的关键要求。
不幸的是,根据定义,不稳定(flaky)单元测试是与这一要求相悖的。如果单元测试在任意两次执行中返回不同的结果(通过或失败),而没有对源代码进行任何底层更改,则该单元测试被认为是不稳定的。
测试代码或正在测试的代码中的程序级不确定性(例如线程顺序和其他并发问题)可能会导致不稳定测试。或者,它的成因可能是测试环境(例如执行它的机器、同时执行的测试集等)的可变性。前者需要修复代码,后者则要找出导致不确定性的原因,并解决它们以消除不稳定因素。代码模式和基础设施的测试必须尽量减少出现不稳定测试的可能性。
不稳定测试会在多个维度上影响开发人员的生产力。首先,当测试由于外来原因失败时,测试人员必须调查问题成因;如果失败的可重复性是不确定的,这就可能非常耗时。在许多情况下,在本地重现故障可能是不切实际的,因为故障需要特定的测试配置和执行环境才能复现。
其次,如果无法确定不稳定性的根本原因,就必须在 CI 期间大量重复测试,以观察到测试的成功运行并合并对应的代码更改。这个过程的两个方面都浪费了关键的开发时间,因此需要构建基础设施支持来处理不稳定单元测试的问题。
我们使用一个简单的示例进一步解释这个问题:
private static int REDIS_PORT = 6380;…@Beforepublic 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。
此外,要重现异常堆栈跟踪,还要适当设置环境(例如,连接到同一端口的测试也应并发执行)。
图 1:测试失败时出现不稳定和可见症状的根本原因
在优步,我们为了利用在单体上开发带来的许多中心化优势,而将各个存储库合并到一个单一的单体存储库中时,不稳定测试带来的痛点进一步加剧了。
这种中心化还带来了由中心化团队来管理依赖项、测试基础设施、构建系统、静态分析等工具的好处。单体存储库节约了为各个独立存储库管理这些系统的总体成本,并保证了整个组织的一致性。
然而,向单体存储库的迁移让影响开发人员生产力的不稳定测试问题更为突出。由于更复杂的执行环境和同时运行的测试数量更多,在独立存储库中不一定不稳定的测试,到了新的单体存储库就变得不稳定了。
由于很多测试最初并非设计为运行在单体存储库规模上,因此在将它们迁移到单体存储库时会产生或暴露出明显的不稳定性,也就不足为奇了。
在本文中,我们将解释我们减轻不稳定测试影响的方法。我们将讨论用于管理单元测试状态和消灭不稳定测试的测试分析器服务(Test Analyzer Service)的设计。
随后,我们将解释我们对各种不稳定性来源进行分类,和构建程序分析工具(自动复现器和静态检查器)的努力,这些努力是为了帮助重现不稳定性故障,并避免在单体存储库中添加新的不稳定测试。最后,我们将分享我们从这个过程中学到的东西。
我们在解决不稳定测试问题时的直接目标,是区分单体存储库中的稳定和不稳定测试。在高层次上,我们可以定期执行单体存储库主分支中的所有单元测试,并记录与每个测试关联的最后 k 次运行的历史记录来实现这一目的。
由于这些测试已经是主分支的一部分,因此它们应该会无条件地成功。如果测试在最后 k 次运行中失败哪怕一次,就将其归类为不稳定并单独处理。
为此,我们构建了一个通用的测试分析器工具,帮助我们大规模分析和可视化优步测试所需的单元测试报告。该工具的核心称为测试分析器服务(TAS),它消费并处理与执行测试相关的数据,以生成可由开发人员可视化和分析的数据。
这种分析能捕获大量测试元数据,包括执行测试的时间、测试执行的频率、上次成功的时间等。该服务运行在优步的各个语言特定的单体存储库上,因此存储了各个库中数十万单元测试的处理信息。
每个单体存储库都有多个 CI 管道,它们定期执行测试并将测试报告提供给 TAS。最近的数据存储在一个本地数据库中,而长期结果存储在一个数据仓库中用于历史分析。我们利用 TAS 建立了一个自定义管道,其目标是在单体存储库的主分支中运行所有单元测试,以帮助识别和分离不稳定测试。
下面的架构图显示了服务的工作流程,一开始运行测试的 CI 作业,然后通过一个测试处理程序(Test Handler)CLI 将结果提供给 TAS,其结果存储在本地数据库和一个数据仓库中。
TAS 通过 API 公开这些数据,以便在测试分析器 UI 中进行可视化和用于进一步分析。代码审查工具已集成到测试分析器工具中,用于可视化结果并更好地理解测试失败。
图 2:测试分析器服务和相关系统的架构
为了检测不稳定测试,我们使用了测试分析器捕获的以下数据:
我们使用这些信息对主分支上的所有测试进行分类,连续 100 次成功运行的测试是稳定的,其余为不稳定。
基于此,一个不稳定测试禁用作业会定期禁止不稳定测试,防止它们影响 CI 相关的结果。换句话说,在为新的代码更改运行测试时,会忽略与不稳定测试相关的失败。下面的图 3 说明了这种情况:
图 3:通过 TAS 进行的不稳定测试分类
由于在代码更改合并时会忽略不稳定测试的结果,当开发人员将更改合并到单体存储库时就能尽量避免不稳定测试的影响。
当然,这会影响可靠性,因为不稳定测试所测试的功能在测试被归类为不稳定的期间是未经测试的。这是我们为保持开发引擎正常运行而做出的慎重权衡。当开发人员修复不稳定的测试并在自定义 CI 管道上连续成功运行 100 次后,这些测试才会被重新归类为稳定,那时这一问题会得到一定程度的改善。
虽然区分不稳定测试和稳定测试是处理这个问题的必要步骤,但这并没有完全解决问题,因为:
我们以分层的方式解决了减少不稳定测试的需求。
最初,我们手动对不稳定性背后的关键原因进行分类和优先度排序,并修复背后的基础设施问题。这有助于减少不稳定测试的总数。但这种方法不可扩展,因为这一过程无法轻松处理不稳定测试症状和根本原因的长尾问题。此外,中心化的开发体验团队没有资源来分类所有有问题的测试用例,也常常不了解每个测试打算验证的团队特定上下文(于是也无法获得解决这些不稳定问题的正确方法)。
因此,为了让任何开发人员都能对不稳定失败进行分类,我们构建了动态复现工具,可在本地再现失败。此外,为了抑制单体存储库中的不稳定测试增长趋势,我们构建了静态检查器,以避免将具有已知不稳定来源的新测试引入单体存储库。后文我们将详细讨论这些策略。
不稳定的测试可以独立地表现出不稳定的行为,也可能由于外部因素(例如运行时环境/基础设施或依赖的库/框架)而变得不稳定。为了理解这一点,我们分析了堆栈跟踪来分类失败原因。从最初的数据中我们发现,大部分不稳定测试是由于外部因素造成的,例如:
由于大多数不稳定测试是源于外部因素的,我们开始以中心化的方式处理它们:
在修复这些导致不稳定现象的基础设施因素的同时,我们还在着手构建复现工具来处理仍然一定会发生的不稳定现象。
开发人员在处理不稳定测试时面临的一个障碍,是他们无法调查不稳定测试的根本成因。这主要是由于他们无法可靠地重现这些失败。
因此,基于不稳定测试的分类和我们自己对其他不稳定测试的修复分析结果,我们构建了动态复现器工具来重现观察到的不稳定测试失败。
我们构建了一个系统,开发人员可以在其中输入测试的详细信息并触发与之相关的自动分析。我们的分析将在各种场景下执行测试,以帮助重现潜在问题。具体来说,它将:
执行测试的前三类是要处理测试方法、类或目标中的任何本地问题。例如,在少数情况下,由于特定测试之间的依赖性(就是说单元测试并不是真正独立的,而是期望由同一测试类中的其他测试进行状态设置),单独运行某个测试方法可以帮助重现失败。应用这个简单的启发式方法可以发现大量的不稳定测试。
根据我们的分析,我们还注意到有许多不稳定测试归因为端口冲突。我们观察到,想要检测出访问相同端口的测试组合,就要同时调用适当的测试组合。
将这一策略应用在数十万个测试的集合上实际上是不可行的。相反,我们设计了一个独立执行每个测试的分析,该分析可以识别出与其他可能的测试出现端口冲突的情况。
为此,我们使用Java安全管理器来识别测试访问的端口集。生成一个名为 Port Claimer 的单独进程来绑定和侦听已识别的端口(同时在 IPv4 和 IPv6 上)。当 Port Claimer 侦听时,测试会重新执行并识别任何新访问的端口集,然后由 Port Claimer 获取。
重复几次这一过程就能收集测试使用的潜在端口集。如果测试使用的是恒定端口,则某次测试重新执行将失败,因为 Port Claimer 侦听了先前标识的端口。否则,测试可以访问新端口。多次重复此过程后,我们可以找出测试使用的一组恒定端口。
如果某个测试执行失败,那么我们可以输出一个简单的复现器命令,该命令将让 Port Claimer 连接一组已识别的端口,然后执行正在考察的不稳定测试。随后,开发人员可以使用它在本地对问题进行分类并修复根本原因。
图 4:通过端口冲突检测工具链确定对可用端口的测试灵敏度
上面的图 4 描述了这个过程。独立运行时,某个不稳定测试可能会成功。安全管理器用于侦听测试访问的端口,并将该信息作为输入提供给 Port Claimer 程序。
当测试与保留已识别端口的 Port Claimer 一起执行时,如果测试失败,则会生成复现器命令。该命令可以声明已识别的端口并在这些条件下运行测试,从而帮助开发人员在本地确定地重现问题。
最后,我们还在节点增加额外负载的条件下运行测试。我们会生成多个进程(类似于stress命令)来实现这一点,并确保测试在这些高 CPU 负载条件下成功。
如果测试具有内部编码的时序依赖性(另一个常见的不稳定来源),则可以立即重现这种不稳定性。我们使用对应的数据输出一个重现命令,开发人员可以使用该命令在所需的压力负载下运行测试,来在本地分类问题。
上述对不稳定测试的分类有助于解决与基础设施相关的故障,和可以集中处理的其他类型的故障。为了扩展修复不稳定测试的过程,我们向全优步的工程师发起了众包修复,并在多个级别上执行这一行动:在针对所有向单体存储库提交代码的开发人员的“修复周”活动中推动修复众包,并对接不稳定测试比例最高的团队。
我们针对部署的努力,以及重现不稳定失败的基础设施和工具支持,在短时间内显著降低了不稳定测试的总体百分比。复现器基础设施还让开发人员可以定期轻松地对较新的不稳定测试进行分类和修复。
在合并到单体存储库后消灭已有的不稳定测试只是任务的一部分。为了提供长期稳定的 CI,我们还希望能先降低引入新的不稳定测试的速度。
我们希望这样做不至于要在每次代码更改的全套测试上运行多个动态复现器。全面的动态分析方法需要我们在每次代码更改时运行许多测试,以寻找潜在的冲突测试用例。
它还需要在不同的动态不稳定复现器下多次运行测试用例。由于这种开销会在代码审查时对开发人员的工作流程产生不可接受的影响,因此自然的解决方案是使用某种形式的轻量级静态分析(也称为 linting)来查找与新添加或修改的测试中的不稳定性相关的已知模式.
在优步,我们主要使用谷歌的Error Prone框架对 Java 代码进行构建时静态分析(另见:NullAway、Piranha)。我们减少测试不稳定性的努力是全方位的,作为这种努力中的一部分,我们已经开始实现简单的 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 的所有影响,从而大大提高了软件可靠性和开发人员生产力。
除了上述工作外,我们正在探索更多减少不稳定测试的机会,包括:
我们要感谢这个项目的其他贡献者(按字母顺序):我们要感谢来自阿姆斯特丹和美国的开发平台团队的几位贡献者,他们为这个项目做出了贡献,包括 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。
原文链接:
领取专属 10元无门槛券
私享最新 技术干货