Skip to content

Implement Parallel Method Execution in JUnit-Vintage engine #4242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0cd9f4d
Add parameters for parallel execution of classes and methods
YongGoose Jan 11, 2025
be5234f
Apply comment
YongGoose Jan 14, 2025
0b70615
Implement parallel execution at class and method level
YongGoose Jan 14, 2025
6e679dc
Update junit-vintage-engine/src/main/java/org/junit/vintage/engine/de…
YongGoose Jan 15, 2025
e92e834
Apply comment
YongGoose Jan 15, 2025
96951ce
Don't reference RunnerScheduler in VintageTestEngine
marcphilipp Jan 16, 2025
78c5c80
Add test code
YongGoose Jan 18, 2025
582df86
Apply comment
YongGoose Jan 29, 2025
3cc7593
Merge branch 'main' into feature/4238
YongGoose Feb 5, 2025
41d5107
Update junit-vintage-engine/src/main/java/org/junit/vintage/engine/Vi…
YongGoose Feb 9, 2025
25fb7f8
Update junit-vintage-engine/src/main/java/org/junit/vintage/engine/Vi…
YongGoose Feb 9, 2025
6f5c9e6
Update junit-vintage-engine/src/main/java/org/junit/vintage/engine/Vi…
YongGoose Feb 9, 2025
55b1dfc
Update junit-vintage-engine/src/main/java/org/junit/vintage/engine/de…
YongGoose Feb 9, 2025
eb68c4f
Update junit-vintage-engine/src/test/java/org/junit/vintage/engine/ex…
YongGoose Feb 9, 2025
b3543fc
Merge branch 'main' into feature/4238
YongGoose Feb 9, 2025
cd31e48
Extract method for better readability
YongGoose Feb 9, 2025
30d8949
Add docs
YongGoose Feb 9, 2025
95aa8eb
Update documentation/src/docs/asciidoc/user-guide/migration-from-juni…
YongGoose Feb 9, 2025
2b4fbee
Extract executeAllChildren method and related calls into VintageExecutor
YongGoose Feb 9, 2025
9c7e81a
Add test for logging warning in `ParallelExecutionIntegrationTests`
YongGoose Feb 9, 2025
ac0be07
Apply comment
YongGoose Feb 9, 2025
b7734a5
Update release-notes
YongGoose Feb 9, 2025
2823bfe
Merge branch 'main' into feature/4238
YongGoose Feb 9, 2025
1872b5a
revert index.adoc
YongGoose Feb 9, 2025
dabea44
Update documentation/src/docs/asciidoc/user-guide/migration-from-juni…
YongGoose Feb 10, 2025
c169178
Update documentation/src/docs/asciidoc/release-notes/release-notes-5.…
YongGoose Feb 10, 2025
9f6670e
Refactor codes
YongGoose Feb 10, 2025
21e8cd1
Update user-guide
YongGoose Feb 10, 2025
a8d3cdb
Merge branch 'main' into feature/4238
YongGoose Feb 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[[release-notes-5.12.0-RC2]]
== 5.12.0-RC2

*Date of Release:*
*Date of Release:* February 14, 2025

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

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

* ❓
* Support for executing test methods in parallel. Please refer to the
<<../user-guide/index.adoc#migrating-from-junit4-parallel-execution, User Guide>> for
more information.
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,20 @@ discovered tests before executing them (see <<running-tests>> for details).
[[migrating-from-junit4-parallel-execution]]
=== Parallel Execution

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

`junit.vintage.execution.parallel.enabled=true|false`::
Enable/disable parallel execution (defaults to `false`).
Enable/disable parallel execution (defaults to `false`). Requires opt-in for `classes`
or `methods` to be executed in parallel using the configuration parameters below.

`junit.vintage.execution.parallel.classes=true|false`::
Enable/disable parallel execution of test classes (defaults to `false`).

`junit.vintage.execution.parallel.methods=true|false`::
Enable/disable parallel execution of test methods (defaults to `false`).

`junit.vintage.execution.parallel.pool-size=<number>`::
Specifies the size of the thread pool to be used for parallel execution. By default, the
Expand All @@ -56,6 +63,8 @@ Example configuration in `junit-platform.properties`:
[source,properties]
----
junit.vintage.execution.parallel.enabled=true
junit.vintage.execution.parallel.classes=true
junit.vintage.execution.parallel.methods=true
junit.vintage.execution.parallel.pool-size=4
----

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ public final class Constants {
@API(status = EXPERIMENTAL, since = "5.12")
public static final String PARALLEL_POOL_SIZE = "junit.vintage.execution.parallel.pool-size";

/**
* Indicates whether parallel execution is enabled for test classes in the JUnit Vintage engine.
*
* <p>Set this property to {@code true} to enable parallel execution of test classes.
* Defaults to {@code false}.
*
* @since 5.12
*/
@API(status = EXPERIMENTAL, since = "5.12")
public static final String PARALLEL_CLASS_EXECUTION = "junit.vintage.execution.parallel.classes";

/**
* Indicates whether parallel execution is enabled for test methods in the JUnit Vintage engine.
*
* <p>Set this property to {@code true} to enable parallel execution of test methods.
* Defaults to {@code false}.
*
* @since 5.12
*/
@API(status = EXPERIMENTAL, since = "5.12")
public static final String PARALLEL_METHOD_EXECUTION = "junit.vintage.execution.parallel.methods";

private Constants() {
/* no-op */
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,20 @@

import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.platform.engine.TestExecutionResult.successful;
import static org.junit.vintage.engine.Constants.PARALLEL_EXECUTION_ENABLED;
import static org.junit.vintage.engine.Constants.PARALLEL_POOL_SIZE;
import static org.junit.vintage.engine.descriptor.VintageTestDescriptor.ENGINE_ID;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import org.apiguardian.api.API;
import org.junit.platform.commons.logging.Logger;
import org.junit.platform.commons.logging.LoggerFactory;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.engine.EngineDiscoveryRequest;
import org.junit.platform.engine.EngineExecutionListener;
import org.junit.platform.engine.ExecutionRequest;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.TestEngine;
import org.junit.platform.engine.UniqueId;
import org.junit.vintage.engine.descriptor.RunnerTestDescriptor;
import org.junit.vintage.engine.descriptor.VintageEngineDescriptor;
import org.junit.vintage.engine.discovery.VintageDiscoverer;
import org.junit.vintage.engine.execution.RunnerExecutor;
import org.junit.vintage.engine.execution.VintageExecutor;

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

private static final Logger logger = LoggerFactory.getLogger(VintageTestEngine.class);

private static final int DEFAULT_THREAD_POOL_SIZE = Runtime.getRuntime().availableProcessors();
private static final int SHUTDOWN_TIMEOUT_SECONDS = 30;

@Override
public String getId() {
return ENGINE_ID;
Expand Down Expand Up @@ -86,96 +67,7 @@ public void execute(ExecutionRequest request) {
EngineExecutionListener engineExecutionListener = request.getEngineExecutionListener();
VintageEngineDescriptor engineDescriptor = (VintageEngineDescriptor) request.getRootTestDescriptor();
engineExecutionListener.executionStarted(engineDescriptor);
executeAllChildren(engineDescriptor, engineExecutionListener, request);
new VintageExecutor(engineDescriptor, engineExecutionListener, request).executeAllChildren();
engineExecutionListener.executionFinished(engineDescriptor, successful());
}

private void executeAllChildren(VintageEngineDescriptor engineDescriptor,
EngineExecutionListener engineExecutionListener, ExecutionRequest request) {
boolean parallelExecutionEnabled = getParallelExecutionEnabled(request);

if (parallelExecutionEnabled) {
if (executeInParallel(engineDescriptor, engineExecutionListener, request)) {
Thread.currentThread().interrupt();
}
}
else {
executeSequentially(engineDescriptor, engineExecutionListener);
}
}

private boolean executeInParallel(VintageEngineDescriptor engineDescriptor,
EngineExecutionListener engineExecutionListener, ExecutionRequest request) {
ExecutorService executorService = Executors.newFixedThreadPool(getThreadPoolSize(request));
RunnerExecutor runnerExecutor = new RunnerExecutor(engineExecutionListener);

List<CompletableFuture<Void>> futures = new ArrayList<>();
for (Iterator<TestDescriptor> iterator = engineDescriptor.getModifiableChildren().iterator(); iterator.hasNext();) {
TestDescriptor descriptor = iterator.next();
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
runnerExecutor.execute((RunnerTestDescriptor) descriptor);
}, executorService);

futures.add(future);
iterator.remove();
}

CompletableFuture<Void> allOf = CompletableFuture.allOf(futures.toArray(new CompletableFuture<?>[0]));
boolean wasInterrupted = false;
try {
allOf.get();
}
catch (InterruptedException e) {
logger.warn(e, () -> "Interruption while waiting for parallel test execution to finish");
wasInterrupted = true;
}
catch (ExecutionException e) {
throw ExceptionUtils.throwAsUncheckedException(e.getCause());
}
finally {
shutdownExecutorService(executorService);
}
return wasInterrupted;
}

private void shutdownExecutorService(ExecutorService executorService) {
try {
executorService.shutdown();
if (!executorService.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
logger.warn(() -> "Executor service did not terminate within the specified timeout");
executorService.shutdownNow();
}
}
catch (InterruptedException e) {
logger.warn(e, () -> "Interruption while waiting for executor service to shut down");
Thread.currentThread().interrupt();
}
}

private void executeSequentially(VintageEngineDescriptor engineDescriptor,
EngineExecutionListener engineExecutionListener) {
RunnerExecutor runnerExecutor = new RunnerExecutor(engineExecutionListener);
for (Iterator<TestDescriptor> iterator = engineDescriptor.getModifiableChildren().iterator(); iterator.hasNext();) {
runnerExecutor.execute((RunnerTestDescriptor) iterator.next());
iterator.remove();
}
}

private boolean getParallelExecutionEnabled(ExecutionRequest request) {
return request.getConfigurationParameters().getBoolean(PARALLEL_EXECUTION_ENABLED).orElse(false);
}

private int getThreadPoolSize(ExecutionRequest request) {
Optional<String> poolSize = request.getConfigurationParameters().get(PARALLEL_POOL_SIZE);
if (poolSize.isPresent()) {
try {
return Integer.parseInt(poolSize.get());
}
catch (NumberFormatException e) {
logger.warn(() -> "Invalid value for parallel pool size: " + poolSize.get());
}
}
return DEFAULT_THREAD_POOL_SIZE;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;

import org.apiguardian.api.API;
Expand All @@ -26,12 +31,16 @@
import org.junit.platform.commons.logging.LoggerFactory;
import org.junit.platform.engine.UniqueId;
import org.junit.platform.engine.support.descriptor.ClassSource;
import org.junit.platform.engine.support.hierarchical.OpenTest4JAwareThrowableCollector;
import org.junit.platform.engine.support.hierarchical.ThrowableCollector;
import org.junit.runner.Description;
import org.junit.runner.Request;
import org.junit.runner.Runner;
import org.junit.runner.manipulation.Filter;
import org.junit.runner.manipulation.Filterable;
import org.junit.runner.manipulation.NoTestsRemainException;
import org.junit.runners.ParentRunner;
import org.junit.runners.model.RunnerScheduler;

/**
* @since 4.12
Expand Down Expand Up @@ -161,6 +170,47 @@ public boolean isIgnored() {
return ignored;
}

public void setExecutorService(ExecutorService executorService) {
Runner runner = getRunnerToReport();
if (runner instanceof ParentRunner) {
((ParentRunner<?>) runner).setScheduler(new RunnerScheduler() {

private final List<Future<?>> futures = new CopyOnWriteArrayList<>();

@Override
public void schedule(Runnable childStatement) {
futures.add(executorService.submit(childStatement));
}

@Override
public void finished() {
ThrowableCollector collector = new OpenTest4JAwareThrowableCollector();
AtomicBoolean wasInterrupted = new AtomicBoolean(false);
for (Future<?> future : futures) {
collector.execute(() -> {
// We're calling `Future.get()` individually to allow for work stealing
// in case `ExecutorService` is a `ForkJoinPool`
try {
future.get();
}
catch (ExecutionException e) {
throw e.getCause();
}
catch (InterruptedException e) {
wasInterrupted.set(true);
}
});
}
collector.assertEmpty();
if (wasInterrupted.get()) {
logger.warn(() -> "Interrupted while waiting for runner to finish");
Thread.currentThread().interrupt();
}
}
});
}
}

private static class ExcludeDescriptionFilter extends Filter {

private final Description description;
Expand Down
Loading