Skip to content

Commit 3e040d0

Browse files
Support parallelization in junit-vintage-engine (#4135)
Top-level test classes are now executed in parallel if the `junit.vintage.execution.parallel.enabled` configuration parameter is set. The size of the used thread pool can be configured using the `junit.vintage.execution.parallel.pool-size` configuration parameter. It defaults to the number of available processors. Resolves #2229. --------- Co-authored-by: Marc Philipp <[email protected]>
1 parent cc2f60c commit 3e040d0

File tree

6 files changed

+351
-2
lines changed

6 files changed

+351
-2
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,6 @@ JUnit repository on GitHub.
160160
[[release-notes-5.12.0-M1-junit-vintage-new-features-and-improvements]]
161161
==== New Features and Improvements
162162

163-
* ❓
163+
* Introduced support for executing top-level test classes in parallel. Please refer to the
164+
<<../user-guide/index.adoc#migrating-from-junit4-parallel-execution, User Guide>> for
165+
more information.

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,31 @@ annotated with `@Category(Example.class)`, it will be tagged with `"com.acme.Exa
3636
Similar to the `Categories` runner in JUnit 4, this information can be used to filter the
3737
discovered tests before executing them (see <<running-tests>> for details).
3838

39+
[[migrating-from-junit4-parallel-execution]]
40+
=== Parallel Execution
41+
42+
The JUnit Vintage test engine supports parallel execution of top-level test classes,
43+
allowing existing JUnit 3 and JUnit 4 tests to benefit from improved performance through
44+
concurrent test execution. It can be enabled and configured using the following
45+
<<running-tests-config-params, configuration parameters>>:
46+
47+
`junit.vintage.execution.parallel.enabled=true|false`::
48+
Enable/disable parallel execution (defaults to `false`).
49+
50+
`junit.vintage.execution.parallel.pool-size=<number>`::
51+
Specifies the size of the thread pool to be used for parallel execution. By default, the
52+
number of available processors is used.
53+
54+
Example configuration in `junit-platform.properties`:
55+
56+
[source,properties]
57+
----
58+
junit.vintage.execution.parallel.enabled=true
59+
junit.vintage.execution.parallel.pool-size=4
60+
----
61+
62+
With these properties set, the `VintageTestEngine` will execute tests in parallel,
63+
potentially significantly reducing the overall test suite execution time.
3964

4065
[[migrating-from-junit4-tips]]
4166
=== Migration Tips
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.vintage.engine;
12+
13+
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
14+
import static org.apiguardian.api.API.Status.STABLE;
15+
16+
import org.apiguardian.api.API;
17+
18+
/**
19+
* Collection of constants related to the {@link VintageTestEngine}.
20+
*
21+
* @since 5.12
22+
*/
23+
@API(status = STABLE, since = "5.12")
24+
public final class Constants {
25+
26+
/**
27+
* Indicates whether parallel execution is enabled for the JUnit Vintage engine.
28+
*
29+
* <p>Set this property to {@code true} to enable parallel execution of tests.
30+
* Defaults to {@code false}.
31+
*
32+
* @since 5.12
33+
*/
34+
@API(status = EXPERIMENTAL, since = "5.12")
35+
public static final String PARALLEL_EXECUTION_ENABLED = "junit.vintage.execution.parallel.enabled";
36+
37+
/**
38+
* Specifies the size of the thread pool to be used for parallel execution.
39+
*
40+
* <p>Set this property to an integer value to specify the number of threads
41+
* to be used for parallel execution. Defaults to the number of available
42+
* processors.
43+
*
44+
* @since 5.12
45+
*/
46+
@API(status = EXPERIMENTAL, since = "5.12")
47+
public static final String PARALLEL_POOL_SIZE = "junit.vintage.execution.parallel.pool-size";
48+
49+
private Constants() {
50+
/* no-op */
51+
}
52+
53+
}

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

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,24 @@
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;
1517
import static org.junit.vintage.engine.descriptor.VintageTestDescriptor.ENGINE_ID;
1618

19+
import java.util.ArrayList;
1720
import java.util.Iterator;
21+
import java.util.List;
1822
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;
1928

2029
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;
2133
import org.junit.platform.engine.EngineDiscoveryRequest;
2234
import org.junit.platform.engine.EngineExecutionListener;
2335
import org.junit.platform.engine.ExecutionRequest;
@@ -37,6 +49,11 @@
3749
@API(status = INTERNAL, since = "4.12")
3850
public final class VintageTestEngine implements TestEngine {
3951

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+
4057
@Override
4158
public String getId() {
4259
return ENGINE_ID;
@@ -69,11 +86,73 @@ public void execute(ExecutionRequest request) {
6986
EngineExecutionListener engineExecutionListener = request.getEngineExecutionListener();
7087
VintageEngineDescriptor engineDescriptor = (VintageEngineDescriptor) request.getRootTestDescriptor();
7188
engineExecutionListener.executionStarted(engineDescriptor);
72-
executeAllChildren(engineDescriptor, engineExecutionListener);
89+
executeAllChildren(engineDescriptor, engineExecutionListener, request);
7390
engineExecutionListener.executionFinished(engineDescriptor, successful());
7491
}
7592

7693
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,
77156
EngineExecutionListener engineExecutionListener) {
78157
RunnerExecutor runnerExecutor = new RunnerExecutor(engineExecutionListener);
79158
for (Iterator<TestDescriptor> iterator = engineDescriptor.getModifiableChildren().iterator(); iterator.hasNext();) {
@@ -82,4 +161,21 @@ private void executeAllChildren(VintageEngineDescriptor engineDescriptor,
82161
}
83162
}
84163

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+
85181
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.vintage.engine.execution;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
import static org.junit.platform.testkit.engine.EventConditions.container;
15+
import static org.junit.platform.testkit.engine.EventConditions.event;
16+
import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
17+
import static org.junit.platform.testkit.engine.EventConditions.started;
18+
import static org.junit.vintage.engine.Constants.PARALLEL_EXECUTION_ENABLED;
19+
import static org.junit.vintage.engine.Constants.PARALLEL_POOL_SIZE;
20+
import static org.junit.vintage.engine.descriptor.VintageTestDescriptor.SEGMENT_TYPE_RUNNER;
21+
import static org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.AbstractBlockingTestCase;
22+
import static org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.FirstTestCase;
23+
import static org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.ThirdTestCase;
24+
25+
import java.time.Instant;
26+
import java.util.Arrays;
27+
import java.util.HashSet;
28+
import java.util.List;
29+
import java.util.concurrent.CountDownLatch;
30+
31+
import org.assertj.core.api.Condition;
32+
import org.junit.jupiter.api.Test;
33+
import org.junit.jupiter.api.TestReporter;
34+
import org.junit.platform.engine.discovery.ClassSelector;
35+
import org.junit.platform.engine.discovery.DiscoverySelectors;
36+
import org.junit.platform.launcher.LauncherDiscoveryRequest;
37+
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
38+
import org.junit.platform.testkit.engine.EngineExecutionResults;
39+
import org.junit.platform.testkit.engine.EngineTestKit;
40+
import org.junit.platform.testkit.engine.Event;
41+
import org.junit.platform.testkit.engine.Events;
42+
import org.junit.vintage.engine.VintageTestEngine;
43+
import org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.SecondTestCase;
44+
45+
class ParallelExecutionIntegrationTests {
46+
47+
@Test
48+
void executesTestClassesInParallel(TestReporter reporter) {
49+
AbstractBlockingTestCase.threadNames.clear();
50+
AbstractBlockingTestCase.countDownLatch = new CountDownLatch(3);
51+
52+
var events = executeInParallelSuccessfully(3, FirstTestCase.class, SecondTestCase.class,
53+
ThirdTestCase.class).list();
54+
55+
var startedTimestamps = getTimestampsFor(events, event(container(SEGMENT_TYPE_RUNNER), started()));
56+
var finishedTimestamps = getTimestampsFor(events,
57+
event(container(SEGMENT_TYPE_RUNNER), finishedSuccessfully()));
58+
var threadNames = new HashSet<>(AbstractBlockingTestCase.threadNames);
59+
60+
reporter.publishEntry("startedTimestamps", startedTimestamps.toString());
61+
reporter.publishEntry("finishedTimestamps", finishedTimestamps.toString());
62+
63+
assertThat(startedTimestamps).hasSize(3);
64+
assertThat(finishedTimestamps).hasSize(3);
65+
assertThat(startedTimestamps).allMatch(startTimestamp -> finishedTimestamps.stream().noneMatch(
66+
finishedTimestamp -> finishedTimestamp.isBefore(startTimestamp)));
67+
assertThat(threadNames).hasSize(3);
68+
}
69+
70+
private List<Instant> getTimestampsFor(List<Event> events, Condition<Event> condition) {
71+
// @formatter:off
72+
return events.stream()
73+
.filter(condition::matches)
74+
.map(Event::getTimestamp)
75+
.toList();
76+
// @formatter:on
77+
}
78+
79+
private Events executeInParallelSuccessfully(int poolSize, Class<?>... testClasses) {
80+
var events = execute(poolSize, testClasses).allEvents();
81+
try {
82+
return events.assertStatistics(it -> it.failed(0));
83+
}
84+
catch (AssertionError error) {
85+
events.debug();
86+
throw error;
87+
}
88+
}
89+
90+
private static EngineExecutionResults execute(int poolSize, Class<?>... testClass) {
91+
return EngineTestKit.execute(new VintageTestEngine(), request(poolSize, testClass));
92+
}
93+
94+
private static LauncherDiscoveryRequest request(int poolSize, Class<?>... testClasses) {
95+
var classSelectors = Arrays.stream(testClasses) //
96+
.map(DiscoverySelectors::selectClass) //
97+
.toArray(ClassSelector[]::new);
98+
99+
return LauncherDiscoveryRequestBuilder.request() //
100+
.selectors(classSelectors) //
101+
.configurationParameter(PARALLEL_EXECUTION_ENABLED, String.valueOf(true)) //
102+
.configurationParameter(PARALLEL_POOL_SIZE, String.valueOf(poolSize)) //
103+
.build();
104+
}
105+
106+
}

0 commit comments

Comments
 (0)