Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
Expand Up @@ -155,4 +155,11 @@ JUnit repository on GitHub.
[[release-notes-5.12.0-M1-junit-vintage-new-features-and-improvements]]
==== New Features and Improvements

* ❓
* Introduced support for parallel execution in the JUnit Vintage engine.
- Added the `junit.vintage.execution.parallel.enabled` property to enable or disable parallel execution.
Defaults to `false`.
- Added the `junit.vintage.execution.parallel.pool-size` property to configure the size of the thread pool used for
parallel execution. Defaults to the number of available processors.
- Enhanced the `VintageTestEngine` to respect these properties and execute tests in parallel when enabled.
- Improved test execution performance by allowing multiple tests to run concurrently, reducing overall
test suite execution time.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,29 @@ annotated with `@Category(Example.class)`, it will be tagged with `"com.acme.Exa
Similar to the `Categories` runner in JUnit 4, this information can be used to filter the
discovered tests before executing them (see <<running-tests>> for details).

[[migrating-from-junit4-parallel-execution]]
=== Parallel Execution Support in JUnit Vintage

JUnit Vintage now supports parallel execution of tests, allowing existing JUnit 3 and
JUnit 4 tests to benefit from improved performance through concurrent test execution.

To enable parallel execution, set the `junit.vintage.execution.parallel.enabled` property
to `true`. By default, this property is set to `false`.

You can also configure the size of the thread pool used for parallel execution by setting
the `junit.vintage.execution.parallel.pool-size` property. By default, this property is
set to the number of available processors.

Example configuration in `junit-platform.properties`:

[source,properties]
----
junit.vintage.execution.parallel.enabled=true
junit.vintage.execution.parallel.pool-size=4
----

With these properties set, the `VintageTestEngine` will execute tests in parallel,
significantly reducing the overall test suite execution time.

[[migrating-from-junit4-tips]]
=== Migration Tips
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,20 @@
import static org.junit.platform.engine.TestExecutionResult.successful;
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;
Expand All @@ -37,6 +47,34 @@
@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;

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

/**
* Specifies the size of the thread pool to be used for parallel execution.
*
* <p>Set this property to an integer value to specify the number of threads
* to be used for parallel execution. Defaults to the number of available
* processors.
*
* @since 5.12
*/
@API(status = INTERNAL, since = "5.12")
public static final String PARALLEL_POOL_SIZE = "junit.vintage.execution.parallel.pool-size";

@Override
public String getId() {
return ENGINE_ID;
Expand Down Expand Up @@ -69,11 +107,73 @@ public void execute(ExecutionRequest request) {
EngineExecutionListener engineExecutionListener = request.getEngineExecutionListener();
VintageEngineDescriptor engineDescriptor = (VintageEngineDescriptor) request.getRootTestDescriptor();
engineExecutionListener.executionStarted(engineDescriptor);
executeAllChildren(engineDescriptor, engineExecutionListener);
executeAllChildren(engineDescriptor, engineExecutionListener, request);
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();) {
Expand All @@ -82,4 +182,21 @@ private void executeAllChildren(VintageEngineDescriptor engineDescriptor,
}
}

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
@@ -0,0 +1,107 @@
/*
* Copyright 2015-2024 the original author or authors.
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License v2.0 which
* accompanies this distribution and is available at
*
* https://www.eclipse.org/legal/epl-v20.html
*/

package org.junit.vintage.engine.execution;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.platform.testkit.engine.EventConditions.container;
import static org.junit.platform.testkit.engine.EventConditions.event;
import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
import static org.junit.platform.testkit.engine.EventConditions.started;
import static org.junit.vintage.engine.descriptor.VintageTestDescriptor.SEGMENT_TYPE_RUNNER;
import static org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.AbstractBlockingTestCase;
import static org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.FirstTestCase;
import static org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.ThirdTestCase;

import java.time.Instant;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.CountDownLatch;

import org.assertj.core.api.Condition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestReporter;
import org.junit.platform.engine.discovery.ClassSelector;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.testkit.engine.EngineExecutionResults;
import org.junit.platform.testkit.engine.EngineTestKit;
import org.junit.platform.testkit.engine.Event;
import org.junit.platform.testkit.engine.Events;
import org.junit.vintage.engine.VintageTestEngine;
import org.junit.vintage.engine.samples.junit4.JUnit4ParallelTestCase.SecondTestCase;

class ParallelExecutionIntegrationTests {

private static final String PARALLEL_EXECUTION_ENABLED = "junit.vintage.execution.parallel.enabled";
private static final String PARALLEL_POOL_SIZE = "junit.vintage.execution.parallel.pool-size";

@Test
void executesTestClassesInParallel(TestReporter reporter) {
AbstractBlockingTestCase.threadNames.clear();
AbstractBlockingTestCase.countDownLatch = new CountDownLatch(3);

var events = executeInParallelSuccessfully(3, FirstTestCase.class, SecondTestCase.class,
ThirdTestCase.class).list();

var startedTimestamps = getTimestampsFor(events, event(container(SEGMENT_TYPE_RUNNER), started()));
var finishedTimestamps = getTimestampsFor(events,
event(container(SEGMENT_TYPE_RUNNER), finishedSuccessfully()));
var threadNames = new HashSet<>(AbstractBlockingTestCase.threadNames);

reporter.publishEntry("startedTimestamps", startedTimestamps.toString());
reporter.publishEntry("finishedTimestamps", finishedTimestamps.toString());

assertThat(startedTimestamps).hasSize(3);
assertThat(finishedTimestamps).hasSize(3);
assertThat(startedTimestamps).allMatch(startTimestamp -> finishedTimestamps.stream().noneMatch(
finishedTimestamp -> finishedTimestamp.isBefore(startTimestamp)));
assertThat(threadNames).hasSize(3);
}

private List<Instant> getTimestampsFor(List<Event> events, Condition<Event> condition) {
// @formatter:off
return events.stream()
.filter(condition::matches)
.map(Event::getTimestamp)
.toList();
// @formatter:on
}

private Events executeInParallelSuccessfully(int poolSize, Class<?>... testClasses) {
var events = execute(poolSize, testClasses).allEvents();
try {
return events.assertStatistics(it -> it.failed(0));
}
catch (AssertionError error) {
events.debug();
throw error;
}
}

private static EngineExecutionResults execute(int poolSize, Class<?>... testClass) {
return EngineTestKit.execute(new VintageTestEngine(), request(poolSize, testClass));
}

private static LauncherDiscoveryRequest request(int poolSize, Class<?>... testClasses) {
var classSelectors = Arrays.stream(testClasses) //
.map(DiscoverySelectors::selectClass) //
.toArray(ClassSelector[]::new);

return LauncherDiscoveryRequestBuilder.request() //
.selectors(classSelectors) //
.configurationParameter(PARALLEL_EXECUTION_ENABLED, String.valueOf(true)) //
.configurationParameter(PARALLEL_POOL_SIZE, String.valueOf(poolSize)) //
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2015-2024 the original author or authors.
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License v2.0 which
* accompanies this distribution and is available at
*
* https://www.eclipse.org/legal/epl-v20.html
*/

package org.junit.vintage.engine.samples.junit4;

import static java.util.concurrent.TimeUnit.MILLISECONDS;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

import org.junit.Rule;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.rules.TestWatcher;
import org.junit.runner.Description;
import org.junit.runner.RunWith;

@RunWith(Enclosed.class)
public class JUnit4ParallelTestCase {

public static class AbstractBlockingTestCase {

public static final Set<String> threadNames = ConcurrentHashMap.newKeySet();
public static CountDownLatch countDownLatch;

@Rule
public final TestWatcher testWatcher = new TestWatcher() {
@Override
protected void starting(Description description) {
AbstractBlockingTestCase.threadNames.add(Thread.currentThread().getName());
}
};

@Test
public void test() throws Exception {
countDownAndBlock(countDownLatch);
}

@SuppressWarnings("ResultOfMethodCallIgnored")
private static void countDownAndBlock(CountDownLatch countDownLatch) throws InterruptedException {
countDownLatch.countDown();
countDownLatch.await(estimateSimulatedTestDurationInMilliseconds(), MILLISECONDS);
}

private static long estimateSimulatedTestDurationInMilliseconds() {
var runningInCi = Boolean.parseBoolean(System.getenv("CI"));
return runningInCi ? 1000 : 100;
}
}

public static class FirstTestCase extends AbstractBlockingTestCase {
}

public static class SecondTestCase extends AbstractBlockingTestCase {
}

public static class ThirdTestCase extends AbstractBlockingTestCase {
}
}
Loading