使用 Gradle 和 JUnit 5 进行并行测试
[LOADING...]
1. 概述
测试是应用程序构建过程中不可或缺的一部分。当然,测试非常耗时,因此我们应该寻找加速测试的方法。最直接的方案就是利用多核处理器并行运行测试。
在本教程中,我们将学习如何使用 Gradle 执行并行测试。
2. Gradle 配置
在本文中,我们将使用 Gradle 9 版本。
首先,让我们配置并行测试。从 build.gradle 文件开始:
请注意,maxParallelForks 属性设置了并行执行测试的线程数。 我们使用了 Java 的 Runtime.availableProcessors() 函数,将可用核心数的一半加一分配给测试。
接下来,通过 useJUnitPlatform 属性,我们调用并配置了 JUnit 5 测试环境。我们使用 includeTags 来允许通过 @Tag 注解过滤测试。为了完成配置,我们需要在 gradle.properties 文件中为 Gradle 的 testForGradleTag 属性设置默认值:
使用此默认设置,我们只会运行标记为 @Tag("serial") 的测试。这是一个可选设置,旨在简化我们的测试流程,它与 Gradle 测试的分叉(forking)机制无关。
3. 工作原理
为了理解并行测试是如何工作的,让我们创建一个名为 UnitTestClass1 的测试类。
首先,准备用于测量单个测试和整个测试套件执行时间的函数:
接下来,添加四个带有 @Test 注解的测试方法。 我们将它们命名为 whenAny_thenCorrect1() 到 whenAny_thenCorrect4()。以下是第一个方法:
目前,它们是完全相同的,除了休眠一秒钟外什么都不做。
最后,tearDown() 方法提供测试统计信息:
我们同时记录了测试显示名称和测试类名,以便跟踪执行顺序。
3.1. 单个测试类
为了仅测试一个类,我们将 UnitTestClass1 的注解更改为 serial:
然后,我们可以使用 Gradle 启动该类的测试:
我们使用 -i 选项启动 ./gradlew 以获取 info 级别的调试信息,然后使用 grep 过滤 tearDown() 方法的输出。让我们研究一下输出结果:
[LOADING...]
可以看到,各个测试方法是串行执行的,一个接一个。 当我们比较后续测试方法的开始时间时,这一点非常明显(黄色框内)。
3.2. 多个测试类
为了从并行测试中受益,我们需要多个测试类,而不是多个测试方法。 因此,我们将 UnitTestClass1 复制三份,将新类编号为 2 到 4,并确保它们都带有 @Tag("parallel") 注解。然后,再次启动测试:
[LOADING...]
现在我们有了 16 个测试运行实例。按时间顺序查看日志时,可以看到不同类的测试方法几乎同时启动。 这意味着 Gradle 同时运行了三个线程。另一方面,如果我们梳理单个类的输出,会发现其内部的方法仍然是串行调用的,就像前面单个类的示例一样。
4. 处理资源
当测试调用资源时,我们需要确保不同测试运行之间的明确区分。 让我们看一个用于创建文件夹的简单类:
mkdir() 函数在文件夹创建失败时返回 false。如果文件夹已存在,就会发生这种情况。
4.1. 集成测试
让我们使用 TestFolderCreator1 测试类来测试此方法。由于我们与操作系统交互,我们将此测试标记为 integration:
该类的结构与 UnitTestClass1 相似。但在 setUp() 方法中,我们预先删除了可能存在的测试文件夹,确保测试环境干净。
现在看看测试函数 whenCreated_ThenCorrect():
它断言文件夹创建成功。与之前的示例不同,这里只有一个测试函数。
最后是 tearDown() 函数:
请注意,此函数在每次测试运行后都会删除测试文件夹。这在串行测试场景中足以处理清理工作。
4.2. 并行测试的多个工作进程
在并行情况下,我们需要确保不同的线程不会尝试创建同一个文件夹。我们通过读取 Gradle 在 org.gradle.test.worker 系统属性中设置的工作进程 ID 来实现这一点:
有了这个 ID,我们就可以为每个工作线程生成不同的文件夹名称。
像之前一样,我们创建该类的四个副本。然后,将额外的 integration 标签传递给 ./gradlew 来运行测试:
让我们在输出中跟踪测试文件夹名称与工作线程之间的依赖关系: [LOADING...]
可以看到,在三个线程的并行运行中,每个类都有自己的工作进程 ID。当工作进程池耗尽后,ID 为 373 的进程被重用。
5. 静态变量的噩梦
现在,让我们检查一个测试依赖于应用程序静态状态的函数的示例。我们将使用一个简单的 单例模式 实现:
我们看到 count 字段累积了请求 ClassSingleton 实例的次数。
然后,我们想测试这个计数器是否设置正确:
这个测试可能会失败,也可能成功,甚至表现得不可预测。通过调整测试类和线程的数量,可以看到结果是不可控的,因此在并行环境下这种测试往往是无效的。
5.1. 如何修复
这种行为产生的原因是静态变量定义在 Java 虚拟机 (JVM) 的作用域内。每个线程都会启动一个单独的 JVM。然而,测试类可能会重用同一个线程。 因此,如果我们多次运行 testSingleton() 函数,就不能指望 count 等于 1。
更糟糕的是,我们将 count 设置为私有字段且没有提供 Setter 方法,因此我们无法在测试前重置单例状态。
Gradle 提供了 forkEvery 属性来解决这个问题,它定义了多少次测试后应该启动新的线程。 如果将其设置为 1,每个测试类都会拥有自己的线程以及独立的 JVM。要设置此属性,需要编辑 build.gradle 文件中的 test 部分:
现在我们的单例测试将总是成功。此外,此功能不仅限于并行测试。如果我们涉及静态上下文的测试用例很多,即使在串行测试场景中也可能需要用到它。 我们只需要将测试放置在不同的类中即可。
最后,应该注意,为每个测试类启动一个新的 JVM 会减慢测试速度。我们可以通过将 forkEvery 设置为 0(默认值)来禁用此功能。
6. 结论
在本文中,我们学习了如何在 Gradle 中运行并行测试。首先,我们配置了 Gradle 以执行此任务。然后,我们研究了测试的执行方式,并得出结论:测试是基于测试类而不是测试方法进行并行的。
接下来,我们探讨了在并行测试中特别棘手的情况,包括资源访问和处理被测应用程序的静态状态。我们看到了 Gradle 如何缓解这些问题。
总而言之,Gradle 的并行测试非常适合加速独立的测试运行。
一如既往,示例代码可在 GitHub 上找到。