按顺序运行测试似乎是Java社区的现状,尽管现在我们的计算机有很多CPU内核。另一方面,并行执行所有这些项目在纸面上可能看起来很棒,但说起来往往容易做起来难,尤其是在已经存在的项目中。

在5.3版本中,JUnit框架引入了对并行测试执行的实验支持,这可以允许由代码驱动的选择性测试并行化。我想提出一个实用的解决方案,它应该适用于许多类型的项目,而不是对该功能进行详尽的概述(官方用户指南在这里做得很好:https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution)。您可以将其视为测试并行化的一个唾手可得的成果。

拟议的方法包括三个步骤:

  1. 启用JUnit 5并行测试执行,但默认情况下按顺序运行所有测试(现状)。

2. 创建自定义的@ParallelizableTest注释,以促进类级并行化(其中的所有测试方法都将并行执行)。

3. 从单元测试开始为所选测试启用并行执行(安全默认设置)。

GitHub上提供了完整的配置(以及一些示例测试用例):https://github.com/mikemybytes/junit5-parallel-tests

启用并行执行

首先,让我们通过创建具有以下内容的junit-platform.properties文件(位于src/test/resources下)来启用JUnit并行执行:

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = same_thread

除了启用特性本身,它还指定:测试类及其测试方法都应按顺序执行。默认情况下,这会保留以前的行为,即测试由同一线程逐个执行。

或者,我们可以通过pom.xml中的Maven Surefire指定JUnit配置:

<build>
  <plugins>
    <plugin>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.0.0-M5</version>
      <configuration>
        <properties>
          <configurationParameters>
            junit.jupiter.execution.parallel.enabled = true
            junit.jupiter.execution.parallel.mode.default = same_thread
            junit.jupiter.execution.parallel.mode.classes.default = same_thread
          </configurationParameters>
        </properties>
      </configuration>
    </plugin>
  </plugins>
</build>

事实上,我建议将这两种方法结合起来,在junit-platform.properties中保留完整的配置,但允许通过专用的系统属性启用/禁用并行测试执行:

<project>
  <properties>
    <parallelTests>true</parallelTests>
  </properties>

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.0.0-M5</version>
        <configuration>
          <properties>
            <configurationParameters>
              junit.jupiter.execution.parallel.enabled = ${parallelTests}
            </configurationParameters>
          </properties>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

这样,默认情况下,所选测试将并行运行,但它们仍然可以按需顺序运行,mvn clean verify -DparallelTests=false

注意:有了所有高级/非标准的JUnit5功能,Surefire版本值得切换到3.x分支,因为那里引入了各种兼容性改进。

并行注释

通过使用Junit 5@Executionhttps://junit.org/junit5/docs/current/api/org.junit.jupiter.api/org/junit/jupiter/api/parallel/Execution.html)注释测试类或测试方法,我们可以控制其并行执行。让我们来看看这个小例子:

Execution(ExecutionMode.CONCURRENT) // note: propagates downstream!
class MyParallelTest { // runs in parallel with other test classes

    @Test
    @Execution(ExecutionMode.CONCURRENT)
    void shouldVerifySomethingImportant() {
        // runs in parallel with other test cases
        // (would behave the same without the annotation - inherited)
    }
    
    @Test
    @Execution(ExecutionMode.SAME_THREAD)
    void shouldVerifySomethingImportantSequentially() {
        // runs in the same thread as its parent (override)
    }
    
    // ...

}

这种在类级别应用的注释将对其内部的所有未注释的测试用例产生影响。因此,一旦我们在测试类级别上启用了并发执行,它的所有测试用例也将并行执行。这意味着,当测试用例彼此完全独立时,应该使用这样的技术。

幸运的是,JUnit 5已经为我们改进了测试用例之间的分离:

为了允许单独的测试方法被隔离执行,并避免由于可变的测试实例状态而产生意外的副作用,JUnit在执行每个测试方法之前为每个测试类创建一个新的实例

这意味着,即使我们有一些共享的非静态字段(例如mock),每个测试用例也会得到自己的实例。这使得在测试类级别上启用并发执行对于大多数用例来说足够安全。

为了促进这样的类级并行化,我们可以创建自己的@ParallelizableTest注释,该注释(与JUnit注释不同)不能用于测试用例(方法)级:

@Execution(ExecutionMode.CONCURRENT) // <- the original JUnit annotation
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
// ^ makes the default "safe behavior" explicit
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) // class-level only
public @interface ParallelizableTest {
}

由于junit-platform.properties的默认顺序设置,现在只有用@ParallelizableTest注释的测试类可以并行运行。这使我们能够轻松地进行逐个测试的选择。

@ParallelizableTest
class MyParallelTest { // runs in parallel with other test classes
    // ...
}

在内部有大量测试代码的现有项目中,可以使用这种技术随着时间的推移迭代地增加并行测试的数量。

与依赖内置注释相比,所提出的方法具有两个优点。首先,它防止我们过于频繁地使用它们——例如,在没有充分理由的情况下混合类和测试用例级别的声明。其次,它显式地启用了“每个方法单独的实例”语义,因此我们不再依赖于可重写的默认值。

选择要并行化的内容

最后,有了所有可用的机制,我们必须决定首先并行化什么。答案可以在一个古老的测试金字塔中找到。

使用JUnit5实现测试并行化插图

单元测试是最容易首先并行化的测试,这并不奇怪。通常,唯一需要做的事情就是用@ParallelizableTest对它们进行注释,因为其他操作应该仍然有效。尽管在减少总执行时间方面是最不有利的,但低工作量使并行化几乎是免费的。事实上,这样做强调了它们与其他测试的内在隔离。

注意:关于“单元测试”的真正含义,似乎有很多争议。为了避免混淆,我引用Vladimir Khorikov的《单元测试:原理、实践和模式》一书中的定义:

单元测试验证单个行为单元,快速执行,并与其他测试隔离执行。

作为下一步,您可能需要选择其他测试类并将它们并行化。虽然这些可能会显著减少执行时间,但所需的工作量也可能会增加。例如,重新使用相同的DB实例可能需要在所有并行测试中进行适当的数据随机化,以防止交叉干扰。在某些用例中,Junit 5更复杂的同步选项也会有所帮助(https://junit.org/junit5/docs/snapshot/user-guide/#writing-tests-parallel-execution-synchronization)。

特别是对于一些非琐碎的测试,由于引入的复杂性,并行化的成本甚至可能超过利润。这就是为什么我认为选择性测试并行化如此有益。

为了本文的目的,我创建了一个由6个测试类组成的小示例项目(https://github.com/mikemybytes/junit5-parallel-tests),每个测试类有3个测试用例(分别命名为ABC)。其中一半可以使用上述配置并行运行。每个测试用例都在开始和结束时打印其线程名称、类和用例名称。运行mvn-clean-verify(并通过配置将并行度限制为6个线程:https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution-config)似乎证明了所提出的设置的正确性:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.mikemybytes.junit.parallel.Parallel1Test
[INFO] Running com.mikemybytes.junit.parallel.Parallel2Test
[INFO] Running com.mikemybytes.junit.parallel.Parallel3Test
[INFO] Running com.mikemybytes.junit.sequential.Sequential3Test
[ForkJoinPool-1-worker-5] START: Parallel3Test#A
[ForkJoinPool-1-worker-6] START: Parallel3Test#B
[ForkJoinPool-1-worker-4] START: Parallel3Test#C
[ForkJoinPool-1-worker-3] START: Parallel2Test#C
[ForkJoinPool-1-worker-1] START: Sequential3Test#A
[ForkJoinPool-1-worker-2] START: Parallel1Test#C
[ForkJoinPool-1-worker-6]   END: Parallel3Test#B
[ForkJoinPool-1-worker-6] START: Parallel2Test#A
[ForkJoinPool-1-worker-3]   END: Parallel2Test#C
[ForkJoinPool-1-worker-4]   END: Parallel3Test#C
[ForkJoinPool-1-worker-3] START: Parallel2Test#B
[ForkJoinPool-1-worker-7] START: Parallel1Test#A
[ForkJoinPool-1-worker-5]   END: Parallel3Test#A
[ForkJoinPool-1-worker-1]   END: Sequential3Test#A
[ForkJoinPool-1-worker-2]   END: Parallel1Test#C
[ForkJoinPool-1-worker-1] START: Sequential3Test#B
[ForkJoinPool-1-worker-5] START: Parallel1Test#B
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.784 s - in com.mikemybytes.junit.parallel.Parallel3Test
[ForkJoinPool-1-worker-6]   END: Parallel2Test#A
[ForkJoinPool-1-worker-1]   END: Sequential3Test#B
[ForkJoinPool-1-worker-1] START: Sequential3Test#C
[ForkJoinPool-1-worker-3]   END: Parallel2Test#B
[ForkJoinPool-1-worker-7]   END: Parallel1Test#A
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.284 s - in com.mikemybytes.junit.parallel.Parallel2Test
[ForkJoinPool-1-worker-5]   END: Parallel1Test#B
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.289 s - in com.mikemybytes.junit.parallel.Parallel1Test
[ForkJoinPool-1-worker-1]   END: Sequential3Test#C
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.547 s - in com.mikemybytes.junit.sequential.Sequential3Test
[INFO] Running com.mikemybytes.junit.sequential.Sequential2Test
[ForkJoinPool-1-worker-1] START: Sequential2Test#A
[ForkJoinPool-1-worker-1]   END: Sequential2Test#A
[ForkJoinPool-1-worker-1] START: Sequential2Test#B
[ForkJoinPool-1-worker-1]   END: Sequential2Test#B
[ForkJoinPool-1-worker-1] START: Sequential2Test#C
[ForkJoinPool-1-worker-1]   END: Sequential2Test#C
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.775 s - in com.mikemybytes.junit.sequential.Sequential2Test
[INFO] Running com.mikemybytes.junit.sequential.Sequential1Test
[ForkJoinPool-1-worker-1] START: Sequential1Test#A
[ForkJoinPool-1-worker-1]   END: Sequential1Test#A
[ForkJoinPool-1-worker-1] START: Sequential1Test#B
[ForkJoinPool-1-worker-1]   END: Sequential1Test#B
[ForkJoinPool-1-worker-1] START: Sequential1Test#C
[ForkJoinPool-1-worker-1]   END: Sequential1Test#C
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.024 s - in com.mikemybytes.junit.sequential.Sequential1Test
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0

标记为可并行的测试彼此并行运行(正如预期的那样),但也与顺序测试并行运行。这一开始可能看起来有点可疑。然而,由于我们的可并行测试声称独立于其他测试,所以它们应该不会对逐个运行的测试造成损害。此外,所有的顺序测试都在同一个线程上执行(ForkJoinPool-1-worker-1)。

局限性

在撰写本文时,所提出的方法的主要局限性与为测试执行生成的Maven Surefire插件报告的准确性有关。在示例项目中,有3个测试类并行执行,每个测试类有3个用例。这意味着我们预计总共会报告9项测试。然而,target/surefire报告中提供的报告似乎表明了一些不同的情况。

-------------------------------------------------------------------------------
Test set: com.mikemybytes.junit.parallel.Parallel1Test
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.289 s - in com.mikemybytes.junit.parallel.Parallel1Test
-------------------------------------------------------------------------------
Test set: com.mikemybytes.junit.parallel.Parallel2Test
-------------------------------------------------------------------------------
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.284 s - in com.mikemybytes.junit.parallel.Parallel2Test
-------------------------------------------------------------------------------
Test set: com.mikemybytes.junit.parallel.Parallel3Test
-------------------------------------------------------------------------------
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.784 s - in com.mikemybytes.junit.parallel.Parallel3Test

这是一个已知的Surefire限制(请参阅Surefire-1643:https://issues.apache.org/jira/browse/SUREFIRE-1643和Surefire-1795:https://issues.apache.org/jira/browse/SUREFIRE-1795),与JUnit 5无关——报告部分仅支持一系列测试事件。

这就是为什么如前所述允许按需顺序执行如此重要的原因。如果出现任何问题(或者某个工具依赖于生成的报告),我们仍然可以使用-DparallelTests=false逐个运行测试。

总结

JUnit5中引入的并行测试执行是一个简单但强大的工具,可以更好地利用我们的硬件资源,缩短反馈循环。只并行运行选定的测试可以让我们完全控制测试执行和“速度与工作量”的权衡。正因为如此,我们可以逐渐提高测试的并行性,而不是一次更改所有内容。尽管有一些限制,但并行测试执行已经是我们开发工具箱中的一个有用的补充。

原文链接:https://mikemybytes.com/2021/11/24/pragmatic-test-parallelization-with-junit5/


免责声明:本文系转载,版权归原作者所有;旨在传递信息,不代表一休教程网的观点和立场。