Ohhnews

分类导航

$ cd ..
Baeldung原文

使用 Gradle 和 JUnit 5 进行并行测试

#gradle#junit 5#并行测试#软件测试#性能优化

[LOADING...]

1. 概述

测试是应用程序构建过程中不可或缺的一部分。当然,测试非常耗时,因此我们应该寻找加速测试的方法。最直接的方案就是利用多核处理器并行运行测试。

在本教程中,我们将学习如何使用 Gradle 执行并行测试。

2. Gradle 配置

在本文中,我们将使用 Gradle 9 版本。

首先,让我们配置并行测试。从 build.gradle 文件开始:

$ java
plugins {
    id 'java-library'
}
test {
    maxParallelForks = (int) (Runtime.runtime.availableProcessors() / 2 + 1)
    useJUnitPlatform {
        includeTags testForGradleTag
    }
}
repositories {
    mavenCentral()
}
dependencies {
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}

请注意,maxParallelForks 属性设置了并行执行测试的线程数。 我们使用了 Java 的 Runtime.availableProcessors() 函数,将可用核心数的一半加一分配给测试。

接下来,通过 useJUnitPlatform 属性,我们调用并配置了 JUnit 5 测试环境。我们使用 includeTags 来允许通过 @Tag 注解过滤测试。为了完成配置,我们需要在 gradle.properties 文件中为 Gradle 的 testForGradleTag 属性设置默认值:

$ properties
testForGradleTag=serial

使用此默认设置,我们只会运行标记为 @Tag("serial") 的测试。这是一个可选设置,旨在简化我们的测试流程,它与 Gradle 测试的分叉(forking)机制无关。

3. 工作原理

为了理解并行测试是如何工作的,让我们创建一个名为 UnitTestClass1 的测试类。

首先,准备用于测量单个测试和整个测试套件执行时间的函数:

$ java
@Tag("parallel")
@Tag("UnitTest")
public class UnitTestClass1 {
    private long start;
    private static long startAll;
    @BeforeAll
    static void beforeAll() {
        startAll = Instant.now().toEpochMilli();
    }
    @AfterAll
    static void afterAll() {
        long endAll = Instant.now().toEpochMilli();
        System.out.println("Total time: " + (endAll - startAll) + " ms");
    }
    @BeforeEach
    void setUp() {
        start = Instant.now().toEpochMilli();
    }
    private LocalTime localTimeFromMilli(long time) {
        return Instant.ofEpochMilli(time)
          .atZone(ZoneId.systemDefault())
          .toLocalTime();
    }
}

接下来,添加四个带有 @Test 注解的测试方法。 我们将它们命名为 whenAny_thenCorrect1()whenAny_thenCorrect4()。以下是第一个方法:

$ java
@Test
public void whenAny_thenCorrect1() throws InterruptedException {
    Thread.sleep(1000L);
    assertTrue(true);
}

目前,它们是完全相同的,除了休眠一秒钟外什么都不做。

最后,tearDown() 方法提供测试统计信息:

$ java
@AfterEach
void tearDown(TestInfo testInfo) {
    long end = Instant.now().toEpochMilli();
    String name = testInfo.getDisplayName();
    System.out.println("Test " + name + " from class " + getClass().getSimpleName() +
      " started at " + localTimeFromMilli(start) + " ended at " + localTimeFromMilli(end) + 
      ": (" + (end - start) + " ms)");
}

我们同时记录了测试显示名称和测试类名,以便跟踪执行顺序。

3.1. 单个测试类

为了仅测试一个类,我们将 UnitTestClass1 的注解更改为 serial

$ java
@Tag("serial")
@Tag("UnitTest")
public class UnitTestClass1

然后,我们可以使用 Gradle 启动该类的测试:

$ bash
$ ./gradlew -i cleanTest test -PtestForGradleTag=serial | grep "Test whenAny"

我们使用 -i 选项启动 ./gradlew 以获取 info 级别的调试信息,然后使用 grep 过滤 tearDown() 方法的输出。让我们研究一下输出结果: [LOADING...]

可以看到,各个测试方法是串行执行的,一个接一个。 当我们比较后续测试方法的开始时间时,这一点非常明显(黄色框内)。

3.2. 多个测试类

为了从并行测试中受益,我们需要多个测试类,而不是多个测试方法。 因此,我们将 UnitTestClass1 复制三份,将新类编号为 24,并确保它们都带有 @Tag("parallel") 注解。然后,再次启动测试:

$ bash
$ ./gradlew -i cleanTest test -PtestForGradleTag=parallel -PtestForGradleTag=UnitTest | grep "Test whenAny"

[LOADING...]

现在我们有了 16 个测试运行实例。按时间顺序查看日志时,可以看到不同类的测试方法几乎同时启动。 这意味着 Gradle 同时运行了三个线程。另一方面,如果我们梳理单个类的输出,会发现其内部的方法仍然是串行调用的,就像前面单个类的示例一样。

4. 处理资源

当测试调用资源时,我们需要确保不同测试运行之间的明确区分。 让我们看一个用于创建文件夹的简单类:

$ java
public class FolderCreator {
    Boolean createFolder(Path path, String name) throws IOException {
        String newFolder = path.toAbsolutePath() + name;
        File f = new File(newFolder);
        return f.mkdir();
    }
}

mkdir() 函数在文件夹创建失败时返回 false。如果文件夹已存在,就会发生这种情况。

4.1. 集成测试

让我们使用 TestFolderCreator1 测试类来测试此方法。由于我们与操作系统交互,我们将此测试标记为 integration

$ java
@Tag("parallel")
@Tag("integration")
public class TestFolderCreator1 {
    // 时间报告辅助函数,同 UnitTestClass1
    private Path baseFolder = Paths.get(
      getClass()
      .getResource("/")
      .getPath());
    private Integer workerID = Integer.valueOf(System.getProperty("org.gradle.test.worker", "1"));
    private String testFolderName = "/" + "Test_" + workerID;
    @BeforeEach
    void setUp() {
        start = Instant.now().toEpochMilli();
        // 使用辅助函数进行预先清理
        removeTestFolder();
    }
    private void removeTestFolder() {
        File folder = new File(
          baseFolder.toFile()
          .getAbsolutePath() + testFolderName);
        folder.delete();
    }
}

该类的结构与 UnitTestClass1 相似。但在 setUp() 方法中,我们预先删除了可能存在的测试文件夹,确保测试环境干净。

现在看看测试函数 whenCreated_ThenCorrect()

$ java
@Test
void whenCreated_ThenCorrect() throws IOException, InterruptedException {
    FolderCreator folderCreator = new FolderCreator();
    assertTrue(folderCreator.createFolder(baseFolder, testFolderName));
    Thread.sleep(1000L);
}

它断言文件夹创建成功。与之前的示例不同,这里只有一个测试函数。

最后是 tearDown() 函数:

$ java
@AfterEach
void tearDown(TestInfo testInfo) {
    long end = Instant.now().toEpochMilli();
    System.out.println(
        "Class " + getClass().getSimpleName() + " checks folder " + testFolderName +
          " started at " + localTimeFromMilli(start) + " ended at " + localTimeFromMilli(end) +
          ": (" + (end - start) + " ms)");
    // 使用辅助函数清理
    removeTestFolder();
}

请注意,此函数在每次测试运行后都会删除测试文件夹。这在串行测试场景中足以处理清理工作。

4.2. 并行测试的多个工作进程

在并行情况下,我们需要确保不同的线程不会尝试创建同一个文件夹。我们通过读取 Gradle 在 org.gradle.test.worker 系统属性中设置的工作进程 ID 来实现这一点:

$ java
System.getProperty("org.gradle.test.worker", "1")

有了这个 ID,我们就可以为每个工作线程生成不同的文件夹名称。

像之前一样,我们创建该类的四个副本。然后,将额外的 integration 标签传递给 ./gradlew 来运行测试:

$ bash
$ ./gradlew -i cleanTest test -PtestForGradleTag=parallel -PtestForGradleTag=integration | grep "Class TestFolder"

让我们在输出中跟踪测试文件夹名称与工作线程之间的依赖关系: [LOADING...]

可以看到,在三个线程的并行运行中,每个类都有自己的工作进程 ID。当工作进程池耗尽后,ID 为 373 的进程被重用。

5. 静态变量的噩梦

现在,让我们检查一个测试依赖于应用程序静态状态的函数的示例。我们将使用一个简单的 单例模式 实现:

$ java
public final class ClassSingleton {
    public String info = "Initial info class";
    private static ClassSingleton INSTANCE;
    private static int count = 0;
    private ClassSingleton() {
    }
    public static ClassSingleton getINSTANCE() {
        return INSTANCE;
    }
    public int getCount() {
        return count;
    }
    public static ClassSingleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new ClassSingleton();
        }
        count++;
        return INSTANCE;
    }
    // 下面还有更多功能...
}

我们看到 count 字段累积了请求 ClassSingleton 实例的次数。

然后,我们想测试这个计数器是否设置正确:

$ java
@Test
public void whenOneRequest_thenSuccess() throws InterruptedException {
    ClassSingleton testSingleton = ClassSingleton.getInstance();
    assertEquals(1, testSingleton.getCount());
    Thread.sleep(1000L);
}

这个测试可能会失败,也可能成功,甚至表现得不可预测。通过调整测试类和线程的数量,可以看到结果是不可控的,因此在并行环境下这种测试往往是无效的。

5.1. 如何修复

这种行为产生的原因是静态变量定义在 Java 虚拟机 (JVM) 的作用域内。每个线程都会启动一个单独的 JVM。然而,测试类可能会重用同一个线程。 因此,如果我们多次运行 testSingleton() 函数,就不能指望 count 等于 1。

更糟糕的是,我们将 count 设置为私有字段且没有提供 Setter 方法,因此我们无法在测试前重置单例状态。

Gradle 提供了 forkEvery 属性来解决这个问题,它定义了多少次测试后应该启动新的线程。 如果将其设置为 1,每个测试类都会拥有自己的线程以及独立的 JVM。要设置此属性,需要编辑 build.gradle 文件中的 test 部分:

$ bash
test {
    maxParallelForks = (int) (Runtime.runtime.availableProcessors() / 2 + 1)
    forkEvery = 1
    useJUnitPlatform {
        includeTags testForGradleTag
    }
}

现在我们的单例测试将总是成功。此外,此功能不仅限于并行测试。如果我们涉及静态上下文的测试用例很多,即使在串行测试场景中也可能需要用到它。 我们只需要将测试放置在不同的类中即可。

最后,应该注意,为每个测试类启动一个新的 JVM 会减慢测试速度。我们可以通过将 forkEvery 设置为 0(默认值)来禁用此功能。

6. 结论

在本文中,我们学习了如何在 Gradle 中运行并行测试。首先,我们配置了 Gradle 以执行此任务。然后,我们研究了测试的执行方式,并得出结论:测试是基于测试类而不是测试方法进行并行的。

接下来,我们探讨了在并行测试中特别棘手的情况,包括资源访问和处理被测应用程序的静态状态。我们看到了 Gradle 如何缓解这些问题。

总而言之,Gradle 的并行测试非常适合加速独立的测试运行。

一如既往,示例代码可在 GitHub 上找到。