Skip to content

Commit 686e49f

Browse files
Add support for parallel execution of test methods to JUnit Vintage engine (#4242)
New configuration parameters allow configuring whether test classes, methods, or both should be executed in parallel. Resolves #4238. --------- Co-authored-by: Marc Philipp <[email protected]>
1 parent fc812f3 commit 686e49f

File tree

9 files changed

+471
-132
lines changed

9 files changed

+471
-132
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-RC2.adoc

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[[release-notes-5.12.0-RC2]]
22
== 5.12.0-RC2
33

4-
*Date of Release:*
4+
*Date of Release:* February 14, 2025
55

66
*Scope:* Minor enhancements since JUnit 5.12.0-RC1.
77

@@ -64,4 +64,6 @@ repository on GitHub.
6464
[[release-notes-5.12.0-RC2-junit-vintage-new-features-and-improvements]]
6565
==== New Features and Improvements
6666

67-
* ❓
67+
* Support for executing test methods in parallel. Please refer to the
68+
<<../user-guide/index.adoc#migrating-from-junit4-parallel-execution, User Guide>> for
69+
more information.

documentation/src/docs/asciidoc/user-guide/migration-from-junit4.adoc

+11-2
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,20 @@ discovered tests before executing them (see <<running-tests>> for details).
3939
[[migrating-from-junit4-parallel-execution]]
4040
=== Parallel Execution
4141

42-
The JUnit Vintage test engine supports parallel execution of top-level test classes,
42+
The JUnit Vintage test engine supports parallel execution of top-level test classes and test methods,
4343
allowing existing JUnit 3 and JUnit 4 tests to benefit from improved performance through
4444
concurrent test execution. It can be enabled and configured using the following
4545
<<running-tests-config-params, configuration parameters>>:
4646

4747
`junit.vintage.execution.parallel.enabled=true|false`::
48-
Enable/disable parallel execution (defaults to `false`).
48+
Enable/disable parallel execution (defaults to `false`). Requires opt-in for `classes`
49+
or `methods` to be executed in parallel using the configuration parameters below.
50+
51+
`junit.vintage.execution.parallel.classes=true|false`::
52+
Enable/disable parallel execution of test classes (defaults to `false`).
53+
54+
`junit.vintage.execution.parallel.methods=true|false`::
55+
Enable/disable parallel execution of test methods (defaults to `false`).
4956

5057
`junit.vintage.execution.parallel.pool-size=<number>`::
5158
Specifies the size of the thread pool to be used for parallel execution. By default, the
@@ -56,6 +63,8 @@ Example configuration in `junit-platform.properties`:
5663
[source,properties]
5764
----
5865
junit.vintage.execution.parallel.enabled=true
66+
junit.vintage.execution.parallel.classes=true
67+
junit.vintage.execution.parallel.methods=true
5968
junit.vintage.execution.parallel.pool-size=4
6069
----
6170

junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java

+22
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,28 @@ public final class Constants {
4646
@API(status = EXPERIMENTAL, since = "5.12")
4747
public static final String PARALLEL_POOL_SIZE = "junit.vintage.execution.parallel.pool-size";
4848

49+
/**
50+
* Indicates whether parallel execution is enabled for test classes in the JUnit Vintage engine.
51+
*
52+
* <p>Set this property to {@code true} to enable parallel execution of test classes.
53+
* Defaults to {@code false}.
54+
*
55+
* @since 5.12
56+
*/
57+
@API(status = EXPERIMENTAL, since = "5.12")
58+
public static final String PARALLEL_CLASS_EXECUTION = "junit.vintage.execution.parallel.classes";
59+
60+
/**
61+
* Indicates whether parallel execution is enabled for test methods in the JUnit Vintage engine.
62+
*
63+
* <p>Set this property to {@code true} to enable parallel execution of test methods.
64+
* Defaults to {@code false}.
65+
*
66+
* @since 5.12
67+
*/
68+
@API(status = EXPERIMENTAL, since = "5.12")
69+
public static final String PARALLEL_METHOD_EXECUTION = "junit.vintage.execution.parallel.methods";
70+
4971
private Constants() {
5072
/* no-op */
5173
}

junit-vintage-engine/src/main/java/org/junit/vintage/engine/VintageTestEngine.java

+2-110
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,20 @@
1212

1313
import static org.apiguardian.api.API.Status.INTERNAL;
1414
import static org.junit.platform.engine.TestExecutionResult.successful;
15-
import static org.junit.vintage.engine.Constants.PARALLEL_EXECUTION_ENABLED;
16-
import static org.junit.vintage.engine.Constants.PARALLEL_POOL_SIZE;
1715
import static org.junit.vintage.engine.descriptor.VintageTestDescriptor.ENGINE_ID;
1816

19-
import java.util.ArrayList;
20-
import java.util.Iterator;
21-
import java.util.List;
2217
import java.util.Optional;
23-
import java.util.concurrent.CompletableFuture;
24-
import java.util.concurrent.ExecutionException;
25-
import java.util.concurrent.ExecutorService;
26-
import java.util.concurrent.Executors;
27-
import java.util.concurrent.TimeUnit;
2818

2919
import org.apiguardian.api.API;
30-
import org.junit.platform.commons.logging.Logger;
31-
import org.junit.platform.commons.logging.LoggerFactory;
32-
import org.junit.platform.commons.util.ExceptionUtils;
3320
import org.junit.platform.engine.EngineDiscoveryRequest;
3421
import org.junit.platform.engine.EngineExecutionListener;
3522
import org.junit.platform.engine.ExecutionRequest;
3623
import org.junit.platform.engine.TestDescriptor;
3724
import org.junit.platform.engine.TestEngine;
3825
import org.junit.platform.engine.UniqueId;
39-
import org.junit.vintage.engine.descriptor.RunnerTestDescriptor;
4026
import org.junit.vintage.engine.descriptor.VintageEngineDescriptor;
4127
import org.junit.vintage.engine.discovery.VintageDiscoverer;
42-
import org.junit.vintage.engine.execution.RunnerExecutor;
28+
import org.junit.vintage.engine.execution.VintageExecutor;
4329

4430
/**
4531
* The JUnit Vintage {@link TestEngine}.
@@ -49,11 +35,6 @@
4935
@API(status = INTERNAL, since = "4.12")
5036
public final class VintageTestEngine implements TestEngine {
5137

52-
private static final Logger logger = LoggerFactory.getLogger(VintageTestEngine.class);
53-
54-
private static final int DEFAULT_THREAD_POOL_SIZE = Runtime.getRuntime().availableProcessors();
55-
private static final int SHUTDOWN_TIMEOUT_SECONDS = 30;
56-
5738
@Override
5839
public String getId() {
5940
return ENGINE_ID;
@@ -86,96 +67,7 @@ public void execute(ExecutionRequest request) {
8667
EngineExecutionListener engineExecutionListener = request.getEngineExecutionListener();
8768
VintageEngineDescriptor engineDescriptor = (VintageEngineDescriptor) request.getRootTestDescriptor();
8869
engineExecutionListener.executionStarted(engineDescriptor);
89-
executeAllChildren(engineDescriptor, engineExecutionListener, request);
70+
new VintageExecutor(engineDescriptor, engineExecutionListener, request).executeAllChildren();
9071
engineExecutionListener.executionFinished(engineDescriptor, successful());
9172
}
92-
93-
private void executeAllChildren(VintageEngineDescriptor engineDescriptor,
94-
EngineExecutionListener engineExecutionListener, ExecutionRequest request) {
95-
boolean parallelExecutionEnabled = getParallelExecutionEnabled(request);
96-
97-
if (parallelExecutionEnabled) {
98-
if (executeInParallel(engineDescriptor, engineExecutionListener, request)) {
99-
Thread.currentThread().interrupt();
100-
}
101-
}
102-
else {
103-
executeSequentially(engineDescriptor, engineExecutionListener);
104-
}
105-
}
106-
107-
private boolean executeInParallel(VintageEngineDescriptor engineDescriptor,
108-
EngineExecutionListener engineExecutionListener, ExecutionRequest request) {
109-
ExecutorService executorService = Executors.newFixedThreadPool(getThreadPoolSize(request));
110-
RunnerExecutor runnerExecutor = new RunnerExecutor(engineExecutionListener);
111-
112-
List<CompletableFuture<Void>> futures = new ArrayList<>();
113-
for (Iterator<TestDescriptor> iterator = engineDescriptor.getModifiableChildren().iterator(); iterator.hasNext();) {
114-
TestDescriptor descriptor = iterator.next();
115-
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
116-
runnerExecutor.execute((RunnerTestDescriptor) descriptor);
117-
}, executorService);
118-
119-
futures.add(future);
120-
iterator.remove();
121-
}
122-
123-
CompletableFuture<Void> allOf = CompletableFuture.allOf(futures.toArray(new CompletableFuture<?>[0]));
124-
boolean wasInterrupted = false;
125-
try {
126-
allOf.get();
127-
}
128-
catch (InterruptedException e) {
129-
logger.warn(e, () -> "Interruption while waiting for parallel test execution to finish");
130-
wasInterrupted = true;
131-
}
132-
catch (ExecutionException e) {
133-
throw ExceptionUtils.throwAsUncheckedException(e.getCause());
134-
}
135-
finally {
136-
shutdownExecutorService(executorService);
137-
}
138-
return wasInterrupted;
139-
}
140-
141-
private void shutdownExecutorService(ExecutorService executorService) {
142-
try {
143-
executorService.shutdown();
144-
if (!executorService.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
145-
logger.warn(() -> "Executor service did not terminate within the specified timeout");
146-
executorService.shutdownNow();
147-
}
148-
}
149-
catch (InterruptedException e) {
150-
logger.warn(e, () -> "Interruption while waiting for executor service to shut down");
151-
Thread.currentThread().interrupt();
152-
}
153-
}
154-
155-
private void executeSequentially(VintageEngineDescriptor engineDescriptor,
156-
EngineExecutionListener engineExecutionListener) {
157-
RunnerExecutor runnerExecutor = new RunnerExecutor(engineExecutionListener);
158-
for (Iterator<TestDescriptor> iterator = engineDescriptor.getModifiableChildren().iterator(); iterator.hasNext();) {
159-
runnerExecutor.execute((RunnerTestDescriptor) iterator.next());
160-
iterator.remove();
161-
}
162-
}
163-
164-
private boolean getParallelExecutionEnabled(ExecutionRequest request) {
165-
return request.getConfigurationParameters().getBoolean(PARALLEL_EXECUTION_ENABLED).orElse(false);
166-
}
167-
168-
private int getThreadPoolSize(ExecutionRequest request) {
169-
Optional<String> poolSize = request.getConfigurationParameters().get(PARALLEL_POOL_SIZE);
170-
if (poolSize.isPresent()) {
171-
try {
172-
return Integer.parseInt(poolSize.get());
173-
}
174-
catch (NumberFormatException e) {
175-
logger.warn(() -> "Invalid value for parallel pool size: " + poolSize.get());
176-
}
177-
}
178-
return DEFAULT_THREAD_POOL_SIZE;
179-
}
180-
18173
}

junit-vintage-engine/src/main/java/org/junit/vintage/engine/descriptor/RunnerTestDescriptor.java

+50
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
import java.util.List;
1919
import java.util.Optional;
2020
import java.util.Set;
21+
import java.util.concurrent.CopyOnWriteArrayList;
22+
import java.util.concurrent.ExecutionException;
23+
import java.util.concurrent.ExecutorService;
24+
import java.util.concurrent.Future;
25+
import java.util.concurrent.atomic.AtomicBoolean;
2126
import java.util.function.Consumer;
2227

2328
import org.apiguardian.api.API;
@@ -26,12 +31,16 @@
2631
import org.junit.platform.commons.logging.LoggerFactory;
2732
import org.junit.platform.engine.UniqueId;
2833
import org.junit.platform.engine.support.descriptor.ClassSource;
34+
import org.junit.platform.engine.support.hierarchical.OpenTest4JAwareThrowableCollector;
35+
import org.junit.platform.engine.support.hierarchical.ThrowableCollector;
2936
import org.junit.runner.Description;
3037
import org.junit.runner.Request;
3138
import org.junit.runner.Runner;
3239
import org.junit.runner.manipulation.Filter;
3340
import org.junit.runner.manipulation.Filterable;
3441
import org.junit.runner.manipulation.NoTestsRemainException;
42+
import org.junit.runners.ParentRunner;
43+
import org.junit.runners.model.RunnerScheduler;
3544

3645
/**
3746
* @since 4.12
@@ -161,6 +170,47 @@ public boolean isIgnored() {
161170
return ignored;
162171
}
163172

173+
public void setExecutorService(ExecutorService executorService) {
174+
Runner runner = getRunnerToReport();
175+
if (runner instanceof ParentRunner) {
176+
((ParentRunner<?>) runner).setScheduler(new RunnerScheduler() {
177+
178+
private final List<Future<?>> futures = new CopyOnWriteArrayList<>();
179+
180+
@Override
181+
public void schedule(Runnable childStatement) {
182+
futures.add(executorService.submit(childStatement));
183+
}
184+
185+
@Override
186+
public void finished() {
187+
ThrowableCollector collector = new OpenTest4JAwareThrowableCollector();
188+
AtomicBoolean wasInterrupted = new AtomicBoolean(false);
189+
for (Future<?> future : futures) {
190+
collector.execute(() -> {
191+
// We're calling `Future.get()` individually to allow for work stealing
192+
// in case `ExecutorService` is a `ForkJoinPool`
193+
try {
194+
future.get();
195+
}
196+
catch (ExecutionException e) {
197+
throw e.getCause();
198+
}
199+
catch (InterruptedException e) {
200+
wasInterrupted.set(true);
201+
}
202+
});
203+
}
204+
collector.assertEmpty();
205+
if (wasInterrupted.get()) {
206+
logger.warn(() -> "Interrupted while waiting for runner to finish");
207+
Thread.currentThread().interrupt();
208+
}
209+
}
210+
});
211+
}
212+
}
213+
164214
private static class ExcludeDescriptionFilter extends Filter {
165215

166216
private final Description description;

0 commit comments

Comments
 (0)