Repository: junit-team/junit5 (language: java)

Issue:
Support some level of parallelization in junit-vintage-engine
## Goal
Add support for some level of parallelization inside the Junit Vintage engine.
Ideally the level of configuration should be similar to maven surefire parallel options.

## Why
When including junit 5 tests in a project containing junit4 tests the natural path is to use junit 5 platform with the jupiter and vintage engines.

However this causes problem with parallelization. 
In a project with Junit4 tests only maven surefire level parallelization works fine. (See https://maven.apache.org/surefire/maven-surefire-plugin/examples/fork-options-and-parallel-execution.html).
For Junit5 tests the jupiter engine [parallel execution works great](https://junit.org/junit5/docs/snapshot/user-guide/#writing-tests-parallel-execution). 

However when a project contains a mix of Junit4 and Junit 5 there is no great way to configure parallelism:
* Surefire parallel does not work with Junit Platform Provider so it's not an option
* Surefire forkCount or gradle maxParallelForks can be used but:
  * It's not available at all when using  [tycho surefire](https://www.eclipse.org/tycho/sitedocs/tycho-surefire-plugin/plugin-info.html)
  * It introduces some memory and execution time overhead compared to surefire parallel or junit 5 parallel.
  * It provides less control than Junit 5 parallel execution options.
* Combining fork + junit 5 parallel execution leads to more parallel executions than expected  when executing junit 5 tests because parallelization is done at two level.

Another option is to configure two distinct test tasks one for junit 4 using surefire parallel and one for junit 5 but it kind of defeats the point of junit platform.

## Deliverables

- [x] Not sure



Discussion / hints:
thanks, @sarod for rising this. 
I tried to fully embrace Junit 5 but had issues working it using the maven failsafe plugin 2.22.2-3.0.0-M4 (Junit 5.3-5.6.2), but I never could make parallelism to work even with settings that worked in maven surefire (in 3 ways to set them).
The issue was the mixed Junit 4,5 testing environment. I moved them all to Junit 5 and it worked like magic in maven failsafe 2.2.22 with parallel execution. 
Are there any known workarounds to achieve this currently? 
I would like to run the junit4 classes in parallel and methods in the same thread.
>  I would like to run the junit4 classes in parallel and methods in the same thread.

That should be fairly straightforward to implement. Basically, one would have to call `RunnerExecutor` concurrently and read a few configuration parameters to enable parallel execution and configure the number of threads:
https://github.com/junit-team/junit5/blob/3f7fed61f2edcee2a5551dbde75382b1dbe21267/junit-vintage-engine/src/main/java/org/junit/vintage/engine/VintageTestEngine.java#L77-L84
@marcphilipp As you suggested running test classes in parallel with Vintage Engine is straightforward and the same is working fine. Is there any suggestion for running the test methods from the same class in parallel? I tried the solution mentioned [here](https://github.com/junit-team/junit5/pull/2449) but it only supports test classes in parallel.
I might be missing something, but so far as I can tell, it is not possible to run tests in parallel with junit-vintage. At the very least, `junit.jupiter.execution.parallel.enabled=true` does not seem to be effective. The PR #2449 looks small and high quality, I hope it or something similar gets merged someday.

If implementing the feature is very difficult, it might be nice if junit-vintage looked for `junit.jupiter.execution.parallel.enabled=true` and warned that it has no effect.
For anybody else who wants this, I was able to get #2449 working on JitPack, took [some tricks](https://github.com/diffplug/l0s-junit5), you can use it like so:

```
maven { url 'https://jitpack.io' }

// testImplementation "org.junit.jupiter:junit-jupiter-api:${VER_junit5}"
// testImplementation "org.junit.vintage:junit-vintage-engine:${VER_junit5}"
testImplementation('com.github.diffplug.l0s-junit5:junit-jupiter-api:7786d92011e4c68255434b6f0f31cfd03841f24b')
testImplementation('com.github.diffplug.l0s-junit5:junit-vintage-engine:7786d92011e4c68255434b6f0f31cfd03841f24b')

test {
  useJUnitPlatform {}
  systemProperty 'junit.vintage.execution.parallel.enabled', 'true'
}
```

Swapping that little dep took a painpoint testsuite from 60 seconds to 10 seconds.
@marcphilipp 

I’m interested in this issue. 
If it’s still open for contributions, would it be okay for me to work on it?
Yes, but let's discuss the plan first. What did you have in mind? Do sth. like I outlined in https://github.com/junit-team/junit5/issues/2229#issuecomment-691659555?

Fix patch:
diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
index a0df1b06b7c3..5c15b26ba6fa 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
@@ -160,4 +160,6 @@ JUnit repository on GitHub.
 [[release-notes-5.12.0-M1-junit-vintage-new-features-and-improvements]]
 ==== New Features and Improvements
 
-* ❓
+* Introduced support for executing top-level test classes in parallel. Please refer to the
+  <<../user-guide/index.adoc#migrating-from-junit4-parallel-execution, User Guide>> for
+  more information.
diff --git a/documentation/src/docs/asciidoc/user-guide/migration-from-junit4.adoc b/documentation/src/docs/asciidoc/user-guide/migration-from-junit4.adoc
index 98e0b056af93..725b113dedc1 100644
--- a/documentation/src/docs/asciidoc/user-guide/migration-from-junit4.adoc
+++ b/documentation/src/docs/asciidoc/user-guide/migration-from-junit4.adoc
@@ -36,6 +36,31 @@ 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
+
+The JUnit Vintage test engine supports parallel execution of top-level test classes,
+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`).
+
+`junit.vintage.execution.parallel.pool-size=<number>`::
+  Specifies the size of the thread pool to be used for parallel execution. By default, the
+  number of available processors is used.
+
+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,
+potentially significantly reducing the overall test suite execution time.
 
 [[migrating-from-junit4-tips]]
 === Migration Tips
diff --git a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java
new file mode 100644
index 000000000000..abb85e3eb61d
--- /dev/null
+++ b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2015-2025 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;
+
+import static org.apiguardian.api.API.Status.EXPERIMENTAL;
+import static org.apiguardian.api.API.Status.STABLE;
+
+import org.apiguardian.api.API;
+
+/**
+ * Collection of constants related to the {@link VintageTestEngine}.
+ *
+ * @since 5.12
+ */
+@API(status = STABLE, since = "5.12")
+public final class Constants {
+
+	/**
+	 * 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 = EXPERIMENTAL, 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 = EXPERIMENTAL, since = "5.12")
+	public static final String PARALLEL_POOL_SIZE = "junit.vintage.execution.parallel.pool-size";
+
+	private Constants() {
+		/* no-op */
+	}
+
+}
diff --git a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/VintageTestEngine.java b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/VintageTestEngine.java
index 9d9eb57c280f..3d6840592891 100644
--- a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/VintageTestEngine.java
+++ b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/VintageTestEngine.java
@@ -12,12 +12,24 @@
 
 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;
@@ -37,6 +49,11 @@
 @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;
@@ -69,11 +86,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();) {
@@ -82,4 +161,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;
+	}
+
 }


Test patch:
diff --git a/junit-vintage-engine/src/test/java/org/junit/vintage/engine/execution/ParallelExecutionIntegrationTests.java b/junit-vintage-engine/src/test/java/org/junit/vintage/engine/execution/ParallelExecutionIntegrationTests.java
new file mode 100644
index 000000000000..9f535007ab77
--- /dev/null
+++ b/junit-vintage-engine/src/test/java/org/junit/vintage/engine/execution/ParallelExecutionIntegrationTests.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2015-2025 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.Constants.PARALLEL_EXECUTION_ENABLED;
+import static org.junit.vintage.engine.Constants.PARALLEL_POOL_SIZE;
+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 {
+
+	@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();
+	}
+
+}
diff --git a/junit-vintage-engine/src/testFixtures/java/org/junit/vintage/engine/samples/junit4/JUnit4ParallelTestCase.java b/junit-vintage-engine/src/testFixtures/java/org/junit/vintage/engine/samples/junit4/JUnit4ParallelTestCase.java
new file mode 100644
index 000000000000..4e8e0c62c8c2
--- /dev/null
+++ b/junit-vintage-engine/src/testFixtures/java/org/junit/vintage/engine/samples/junit4/JUnit4ParallelTestCase.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2015-2025 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 {
+	}
+}