Skip to content

JUnit并发测试

本文介绍如何在JUnit中并发运行测试。

1. 并发测试基础

默认情况下,JUnit测试是在单线程中顺序执行的,但是,也可以通过配置,使得测试可以并发执行。

首先,在junit-platform.properties配置文件中开启并行测试:

properties
junit.jupiter.execution.parallel.enabled=true

然后,在测试类或测试方法上通过注解@Execution标注执行模式,取值如下:

  • SAME_THREAD:强制在父线程中执行。
    • 当在测试方法上使用时,该测试方法将在包含测试类的任何@BeforeAll@AfterAll方法的同一线程中执行,换句话说,执行@BeforeAll方法的线程会执行该测试方法;
    • 如果标注在测试类上,该测试类的所有子节点(测试方法)必须在同一个线程中串行;
  • CONCURRENT:除非资源锁迫使在同一线程中执行,否则将并发执行;

例如,下面的案例在测试类上标注了并发执行:

java
@Execution(ExecutionMode.CONCURRENT)
public class DemoTest01 {

    @Test
    void test01(){
        System.out.println(Thread.currentThread().getName());
    }

    @Test
    void test02(){
        System.out.println(Thread.currentThread().getName());
    }

}

结果如下:

txt
ForkJoinPool-1-worker-1
ForkJoinPool-1-worker-2

可以看到,两个测试方法在不同的线程中执行。

默认情况下,测试执行都是在SAME_THREAD模式下,也可以全局配置,在junit-platform.properties配置文件中配置如下:

properties
junit.jupiter.execution.parallel.mode.default = same_thread或concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent或same_thread
  • junit.jupiter.execution.parallel.mode.default:配置测试方法的默认执行模式;

  • junit.jupiter.execution.parallel.mode.classes.default:配置顶层测试类的默认执行模式,如果该值没有配置,则使用junit.jupiter.execution.parallel.mode.default的值;

这样就有多种模式,假设现在有两个测试类:

java
public class TestA{
    @Test
    void methodA01(){
        
    }
    
    @Test
    void methodA02(){
        
    }
}
java
public class TestB{
    @Test
    void methodB01(){
        
    }
    
    @Test
    void methodB02(){
        
    }
}

根据不同的配置,执行如下:

(方法执行模式, 类执行模式)执行顺序
(same_thread, same_thread)单线程串行执行TestA.methodA01(),TestA.methodA02(),TestB.methodB01(),TestB.methodB02()
(same_thread, concurrent)线程1串行执行TestA.methodA01(),TestA.methodA02()
线程2串行执行TestB.methodB01(),TestB.methodB02()
(concurrent, same_thread)类排队,当轮到测试类执行时,该类中的方法并发执行,可能的情况如下:
线程1执行TestA.methodA01(),TestB.methodB01()
线程2执行TestA.methodA02(),TestB.methodB02()
(concurrent, concurrent)如果线程数足够,那么所有的方法都可以并发执行,可能的情况如下:
线程1执行TestA.methodA01()
线程2执行TestA.methodA02()
线程3执行TestB.methodB01()
线程4执行TestB.methodB02()

当测试方法全局默认执行模式为concurrent时,有以下两种情况需要在测试类或测试方法上显式标注@Execution(CONCURRENT),才可以并发执行:

  • 设置测试类实例为Lifecycle.PER_CLASS,因为这种情况下,测试类实例可能不是线程安全的;
  • 设置测试方法执行顺序MethodOrderer,方法执行顺序与并发执行矛盾;

如果设置了ClassOrderer,测试类会排序,然后依序调度,仍然可以并发,但是并不能保证按顺序执行(只是按顺序调度)。

2. 设置线程池

如果启用并发测试,并发度和最大线程数量可以通过实现ParallelExecutionConfigurationStrategy接口决定,JUnit提供了两种默认实现:

  • dynamic:根据CPU核心数计算并发度,计算公式为parallelism=availableProcessors×factor,其中availableProcessors表示CPU核心数,factor由配置junit.jupiter.execution.parallel.config.dynamic.factor决定(默认值为1);

    如果选择dynamic策略,还可以使用junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor来限制最大线程数,默认值为256+parallelism ;

  • fixed:直接指定一个具体的数字作为parallelism,具体使用配置junit.jupiter.execution.parallel.config.fixed.parallelism指定;

    如果选择fixed策略,可以使用junit.jupiter.execution.parallel.config.fixed.max-pool-size来限制最大线程数,默认值为256+parallelism ;

除了dynamicfixed,还可以使用custome策略,表示自己实现ParallelExecutionConfigurationStrategy接口控制并发度:

  • custom:通过配置项junit.jupiter.execution.parallel.config.custom.class,指定自定义的并发配置实现;

以上策略可以配置如下:

properties
junit.jupiter.execution.parallel.config.strategy=dynamic

如果没有配置,默认值就是dynamic

TIP

什么是并发度?

JUnit 底层使用 Java 的 ForkJoinPool。这个线程池有一个特性叫补偿机制:如果一个测试线程因为某些原因(比如调用了 Thread.sleep()、在等待网络 I/O、或者在等 @ResourceLock 锁释放)而进入阻塞状态,ForkJoinPool 会认为这个线程现在“废了”,没法干活了。 为了保证设定的 parallelism(并行度)不缩水,线程池会临时新开一个线程(saturate)来补位。

线程池也不可能无线新开线程补位,所以会设置max-pool-size来限制最大线程数量。

在JUnit中,也可以控制是否临时新开一个线程来保持并发度,分别设置以下配置:

properties
junit.jupiter.execution.parallel.config.dynamic.saturate=true
junit.jupiter.execution.parallel.config.fixed.saturate=true
  • true:默认值,表示要临时新开线程保持并发度;
  • false:不新开线程保持并发度;

3. 同步机制

有并发,那么就有同步的需要,在JUnit中,提供了一种便捷的同步机制:@ResourceLock

@ResourceLock允许标注在测试类或测试方法上,使用指定的共享资源用于同步,共享资源为字符串,JUnit提供了一些系统资源,在org.junit.jupiter.api.parallel.Resources类中,包括: SYSTEM_PROPERTIES, SYSTEM_OUT, SYSTEM_ERR, LOCALE, TIME_ZONE

例如,下面的例子中,两个测试方法并发执行:

java
@Execution(ExecutionMode.CONCURRENT)
public class DemoTest01 {

    @Test
    void test01(){
        System.setProperty("test.name", "DemoTest01.test01");
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        Assertions.assertEquals("DemoTest01.test01", System.getProperty("test.name"));
    }

    @Test
    void test02(){
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.setProperty("test.name", "DemoTest01.test02");
    }

}

其中一个测试方法test02修改了系统配置,另一个测试方法test01依赖于系统配置,允许以上测试类,会发现test01失败了,这就是由于测试方法没有做同步控制。我们可以使用@ResourceLock来控制测试方法的同步:

java
@Execution(ExecutionMode.CONCURRENT)
public class DemoTest01 {

    @Test
    @ResourceLock(value = Resources.SYSTEM_PROPERTIES)
    void test01(){
        System.out.println(Thread.currentThread().getName());
        System.setProperty("test.name", "DemoTest01.test01");
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        Assertions.assertEquals("DemoTest01.test01", System.getProperty("test.name"));
    }

    @Test
    @ResourceLock(value = Resources.SYSTEM_PROPERTIES)
    void test02(){
        System.out.println(Thread.currentThread().getName());
        
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.setProperty("test.name", "DemoTest01.test02");
    }

}

这就相当于test01在开始运行的时候,先获取Resources.SYSTEM_PROPERTIES锁,然后test02方法开始运行,也获取锁Resources.SYSTEM_PROPERTIES,但是由于此时锁被test01获取了,所以test02只能阻塞,等待test01执行完成释放锁后,test02才继续执行。

除了指定锁的资源,@ResourceLock还可以指定锁类型:

  • ResourceAccessMode.READ_WRITE:给资源加读写锁,会阻塞任何其他试图获取资源读锁/读写锁的线程,默认值;
  • ResourceAccessMode.READ:给资源加读锁,会阻塞任何其他试图获取资源读写锁的线程,不会阻塞任何其他试图获取资源读锁的线程;

如果某个测试方法只是读取资源,那么可以给该测试方法上加上读锁,减小锁的粒度,增加并发能力。

注意,如果一个测试方法上加了@ResourceLock,那么加锁时机是在@BeforeEach之前,释放锁时机是在@AfterEach之后。

如果在测试类上加上@ResourceLock,那么表示锁定整个测试类,造成的结果如下:

  • 作用: 意味着在同一时刻,只有一个线程可以运行这个类中的任何测试方法

  • 与其他类的关系: 如果有另一个测试类也声明了相同的资源锁,那么这两个类将互斥执行(一个跑完,另一个才能开始)。

  • 类内部方法: 即使全局配置了方法级别并行,由于类级别背负了这个锁,该类内部的所有方法通常会退化为顺序执行

java
@Execution(ExecutionMode.CONCURRENT)
@ResourceLock(value = Resources.SYSTEM_PROPERTIES)
public class DemoTest01 {

    @Test
    void test01(){
        System.out.println("start:" + LocalDateTime.now() + "  DemoTest01.test01  " + Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("end:" +LocalDateTime.now() + "  DemoTest01.test01  " + Thread.currentThread().getName());
    }

    @Test
    void test02(){
        System.out.println("start:" +LocalDateTime.now() + "  DemoTest01.test02  " + Thread.currentThread().getName());
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("end:" +LocalDateTime.now() + "  DemoTest01.test02  " + Thread.currentThread().getName());
    }

}
java
@Execution(ExecutionMode.CONCURRENT)
@ResourceLock(value = Resources.SYSTEM_PROPERTIES)
public class DemoTest02 {

    @Test
    void test01(){
        System.out.println("start:" +LocalDateTime.now() + "  DemoTest02.test01  " + Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("end:" +LocalDateTime.now() + "  DemoTest02.test01  " + Thread.currentThread().getName());
    }

    @Test
    void test02(){
        System.out.println("start:" +LocalDateTime.now() + "  DemoTest02.test02  " + Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("end:" +LocalDateTime.now() + "  DemoTest02.test02  " + Thread.currentThread().getName());
    }
}

在IDEA中同时运行多个测试类:

1、在 IDEA 左侧的 Project 导航栏中,按住 Ctrl (Windows/Linux) 或 Command (Mac) 键。

2、用鼠标点击选中 DemoTest01DemoTest02 两个文件。

3、右键点击其中一个选中的文件,选择 Run 'Tests in '...' '

4、IDEA 会创建一个临时的运行配置,控制台会按顺序打印这两个类的输出。

执行结果如下:

txt
start:2026-03-06T18:15:26.579611600  DemoTest01.test01  ForkJoinPool-1-worker-2
end:2026-03-06T18:15:27.588079300  DemoTest01.test01  ForkJoinPool-1-worker-2
start:2026-03-06T18:15:27.589047200  DemoTest01.test02  ForkJoinPool-1-worker-2
end:2026-03-06T18:15:29.591997900  DemoTest01.test02  ForkJoinPool-1-worker-2
start:2026-03-06T18:15:24.540385700  DemoTest02.test01  ForkJoinPool-1-worker-1
end:2026-03-06T18:15:25.555832400  DemoTest02.test01  ForkJoinPool-1-worker-1
start:2026-03-06T18:15:25.567801400  DemoTest02.test02  ForkJoinPool-1-worker-1
end:2026-03-06T18:15:26.574609900  DemoTest02.test02  ForkJoinPool-1-worker-1

可以发现,测试类和测试方法都变成串行的了。

@ResourceLock中,还有一个属性target,取值如下:

  • SELF:表示@ResourceLock作用于自身,默认值。

  • CHILDREN: 锁不再作用于类本身,而是向下传播。它相当于给该类下的每一个测试方法/嵌套测试类都自动加上了相同的 @ResourceLock

    使用这种方法,可以免去手动给所有的测试方法都加上该注解。

在上面的例子中,我们将类上的注解改为:

java
@ResourceLock(value = Resources.SYSTEM_PROPERTIES, target = ResourceLockTarget.CHILDREN)

会发现所有的测试方法仍然是串行执行的,但是,重要的一个变化是,一个测试类并不是要等另一个测试类中测试方法都执行完后再执行,结果:

txt
start:2026-03-06T18:29:12.018214  DemoTest02.test01  ForkJoinPool-1-worker-3
end:2026-03-06T18:29:13.033421900  DemoTest02.test01  ForkJoinPool-1-worker-3
start:2026-03-06T18:29:13.052444700  DemoTest01.test01  ForkJoinPool-1-worker-4
end:2026-03-06T18:29:14.054778400  DemoTest01.test01  ForkJoinPool-1-worker-4
start:2026-03-06T18:29:14.056751100  DemoTest02.test02  ForkJoinPool-1-worker-1
end:2026-03-06T18:29:15.064122200  DemoTest02.test02  ForkJoinPool-1-worker-1
start:2026-03-06T18:29:15.065123  DemoTest01.test02  ForkJoinPool-1-worker-2
end:2026-03-06T18:29:17.079928700  DemoTest01.test02  ForkJoinPool-1-worker-2

方法之间的串行是由于mode的默认值是读写锁,是互斥的。

再次修改mode

java
@ResourceLock(value = Resources.SYSTEM_PROPERTIES, target = ResourceLockTarget.CHILDREN, mode = ResourceAccessMode.READ)
txt
start:2026-03-06T18:32:57.137770100  DemoTest01.test01  ForkJoinPool-1-worker-4
start:2026-03-06T18:32:57.137770100  DemoTest02.test02  ForkJoinPool-1-worker-1
start:2026-03-06T18:32:57.137770100  DemoTest01.test02  ForkJoinPool-1-worker-2
start:2026-03-06T18:32:57.137770100  DemoTest02.test01  ForkJoinPool-1-worker-3
end:2026-03-06T18:32:58.139615900  DemoTest02.test01  ForkJoinPool-1-worker-3
end:2026-03-06T18:32:59.139453500  DemoTest01.test02  ForkJoinPool-1-worker-2
end:2026-03-06T18:32:58.139615900  DemoTest01.test01  ForkJoinPool-1-worker-4
end:2026-03-06T18:32:58.139615900  DemoTest02.test02  ForkJoinPool-1-worker-1

可以发现,所有的测试方法都是同一时间由不同线程运行。

加入测试类和测试方法上都加了@ResourceLock,那么会出现什么情况呢?

**情形一:**测试类加上了读锁,并且target为默认值SELF,测试方法1加上了读写锁

java
@ResourceLock(value = "MY_RES", mode = ResourceAccessMode.READ) // 类级别:读
class MyTest {

    @Test
    @ResourceLock(value = "MY_RES", mode = ResourceAccessMode.READ_WRITE) // 方法级别:写
    void testMethod01() { ... }
  
  	@Test
    void testMethod02() { ... }
  
  	@Test
    void testMethod03() { ... }
}

在这种情况下,该类及其所有方法将退化为串行执行(SAME_THREAD模式),也就是说,测试方法1、2、3都在同一个线程中执行。

情形二: 测试类加上了读锁,并且target设置为了CHILDREN,测试方法1加上了读写锁

java
@ResourceLock(value = "MY_RES", mode = ResourceAccessMode.READ, target=ResourceLockTarget.CHILDREN) 
class MyTest {

    @Test
    @ResourceLock(value = "MY_RES", mode = ResourceAccessMode.READ_WRITE) // 方法级别:写
    void testMethod01() { ... }
  
  	@Test
    void testMethod02() { ... }
  
  	@Test
    void testMethod03() { ... }
}

在这种情况下,测试方法1的读写锁会覆盖掉继承而来的读锁,其他只有 READ 锁的方法可以同时跑,但当轮到这个 READ_WRITE 方法时,它会等待其他读锁释放,然后独占运行。

在JUnit中,还提供了@Isolated注解,用于使某测试类进入独占模式,也就是说:

  • 当一个类标注了 @Isolated,该类中的测试方法会串行执行;
  • 当一个类标注了 @Isolated,这个类运行时,JUnit 会进入**“独占状态”,它会确保没有任何其他并发测试类**在运行;

参考资料

[1] https://docs.junit.org/6.0.3/writing-tests/parallel-execution.html