Appearance
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_threadjunit.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核心数计算并发度,计算公式为,其中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 ;
除了dynamic和fixed,还可以使用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=truetrue:默认值,表示要临时新开线程保持并发度;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、用鼠标点击选中
DemoTest01和DemoTest02两个文件。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