diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java
index 1b0636f18d10..8f62f93309fa 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java
@@ -15,9 +15,11 @@
import static org.apiguardian.api.API.Status.STABLE;
import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_CUSTOM_CLASS_PROPERTY_NAME;
import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME;
+import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_DYNAMIC_TEST_EXECUTOR_PROPERTY_NAME;
import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME;
import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME;
import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_SATURATE_PROPERTY_NAME;
+import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME;
import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_STRATEGY_PROPERTY_NAME;
import org.apiguardian.api.API;
@@ -176,7 +178,7 @@ public final class Constants {
* {@value #PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME}; defaults to
* {@code 256 + fixed.parallelism}.
*
- *
Note: This property only takes affect on Java 9+.
+ *
Note: This property only takes effect on Java 9+.
*
* @since 5.10
*/
@@ -194,7 +196,7 @@ public final class Constants {
*
Value must either {@code true} or {@code false}; defaults to {@code true}.
*
- *
Note: This property only takes affect on Java 9+.
+ *
Note: This property only takes effect on Java 9+.
*
* @since 5.10
*/
@@ -202,6 +204,19 @@ public final class Constants {
public static final String PARALLEL_CONFIG_FIXED_SATURATE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX
+ CONFIG_FIXED_SATURATE_PROPERTY_NAME;
+ /**
+ * Property name used to set the type of test executor
+ * (which directly relates to the type of thread pool used)
+ * for the {@code fixed} configuration strategy: {@value}
+ *
+ *
Value must be either {@code fork_join} or {@code fixed_threads}; defaults to {@code fork_join}.
+ *
+ * @since 5.11
+ */
+ @API(status = EXPERIMENTAL, since = "5.11")
+ public static final String PARALLEL_CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX
+ + CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME;
+
/**
* Property name used to set the factor to be multiplied with the number of
* available processors/cores to determine the desired parallelism for the
@@ -215,6 +230,19 @@ public final class Constants {
public static final String PARALLEL_CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX
+ CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME;
+ /**
+ * Property name used to set the type of test executor
+ * (which directly relates to the type of thread pool used)
+ * for the {@code dynamic} configuration strategy: {@value}
+ *
+ *
Value must be either {@code fork_join} or {@code fixed_threads}; defaults to {@code fork_join}.
+ *
+ * @since 5.11
+ */
+ @API(status = EXPERIMENTAL, since = "5.11")
+ public static final String PARALLEL_CONFIG_DYNAMIC_TEST_EXECUTOR_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX
+ + CONFIG_DYNAMIC_TEST_EXECUTOR_PROPERTY_NAME;
+
/**
* Property name used to specify the fully qualified class name of the
* {@link ParallelExecutionConfigurationStrategy} to be used for the
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfiguration.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfiguration.java
index cb91d2a84639..84df8031908a 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfiguration.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfiguration.java
@@ -24,15 +24,17 @@ class DefaultParallelExecutionConfiguration implements ParallelExecutionConfigur
private final int corePoolSize;
private final int keepAliveSeconds;
private final Predicate super ForkJoinPool> saturate;
+ private final TestExecutor testExecutor;
DefaultParallelExecutionConfiguration(int parallelism, int minimumRunnable, int maxPoolSize, int corePoolSize,
- int keepAliveSeconds, Predicate super ForkJoinPool> saturate) {
+ int keepAliveSeconds, Predicate super ForkJoinPool> saturate, TestExecutor testExecutor) {
this.parallelism = parallelism;
this.minimumRunnable = minimumRunnable;
this.maxPoolSize = maxPoolSize;
this.corePoolSize = corePoolSize;
this.keepAliveSeconds = keepAliveSeconds;
this.saturate = saturate;
+ this.testExecutor = testExecutor;
}
@Override
@@ -64,4 +66,9 @@ public int getKeepAliveSeconds() {
public Predicate super ForkJoinPool> getSaturatePredicate() {
return saturate;
}
+
+ @Override
+ public TestExecutor getTestExecutor() {
+ return testExecutor;
+ }
}
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java
index fc5ac9e257fe..e4b2136ba071 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java
@@ -12,6 +12,7 @@
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.apiguardian.api.API.Status.STABLE;
+import static org.junit.platform.engine.support.hierarchical.ParallelExecutionConfiguration.TestExecutor;
import java.math.BigDecimal;
import java.util.Locale;
@@ -21,6 +22,7 @@
import org.junit.platform.commons.util.Preconditions;
import org.junit.platform.commons.util.ReflectionUtils;
import org.junit.platform.engine.ConfigurationParameters;
+import org.junit.platform.engine.TestDescriptor;
/**
* Default implementations of configuration strategies for parallel test
@@ -49,8 +51,11 @@ public ParallelExecutionConfiguration createConfiguration(ConfigurationParameter
boolean saturate = configurationParameters.get(CONFIG_FIXED_SATURATE_PROPERTY_NAME,
Boolean::valueOf).orElse(true);
+ TestExecutor testExecutor = configurationParameters.get(CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME,
+ (str) -> TestExecutor.valueOf(str.toUpperCase())).orElse(TestExecutor.FORK_JOIN);
+
return new DefaultParallelExecutionConfiguration(parallelism, parallelism, maxPoolSize, parallelism,
- KEEP_ALIVE_SECONDS, __ -> saturate);
+ KEEP_ALIVE_SECONDS, __ -> saturate, testExecutor);
}
},
@@ -84,8 +89,11 @@ public ParallelExecutionConfiguration createConfiguration(ConfigurationParameter
boolean saturate = configurationParameters.get(CONFIG_DYNAMIC_SATURATE_PROPERTY_NAME,
Boolean::valueOf).orElse(true);
+ TestExecutor testExecutor = configurationParameters.get(CONFIG_DYNAMIC_TEST_EXECUTOR_PROPERTY_NAME,
+ (str) -> TestExecutor.valueOf(str.toUpperCase())).orElse(TestExecutor.FORK_JOIN);
+
return new DefaultParallelExecutionConfiguration(parallelism, parallelism, maxPoolSize, parallelism,
- KEEP_ALIVE_SECONDS, __ -> saturate);
+ KEEP_ALIVE_SECONDS, __ -> saturate, testExecutor);
}
},
@@ -163,6 +171,16 @@ public ParallelExecutionConfiguration createConfiguration(ConfigurationParameter
@API(status = EXPERIMENTAL, since = "1.10")
public static final String CONFIG_FIXED_SATURATE_PROPERTY_NAME = "fixed.saturate";
+ /**
+ * Property name used to disable saturation of the underlying thread pool
+ * used to execute {@linkplain TestDescriptor.Type#TEST test tasks}
+ * for the {@link #FIXED} configuration strategy.
+ *
+ * @since 1.11
+ */
+ @API(status = EXPERIMENTAL, since = "1.11")
+ public static final String CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME = "fixed.test-executor";
+
/**
* Property name of the factor used to determine the desired parallelism for the
* {@link #DYNAMIC} configuration strategy.
@@ -207,6 +225,16 @@ public ParallelExecutionConfiguration createConfiguration(ConfigurationParameter
@API(status = EXPERIMENTAL, since = "1.10")
public static final String CONFIG_DYNAMIC_SATURATE_PROPERTY_NAME = "dynamic.saturate";
+ /**
+ * Property name used to disable saturation of the underlying thread pool
+ * used to execute {@linkplain TestDescriptor.Type#TEST test tasks}
+ * for the {@link #DYNAMIC} configuration strategy.
+ *
+ * @since 1.11
+ */
+ @API(status = EXPERIMENTAL, since = "1.11")
+ public static final String CONFIG_DYNAMIC_TEST_EXECUTOR_PROPERTY_NAME = "dynamic.test-executor";
+
/**
* Property name used to specify the fully qualified class name of the
* {@link ParallelExecutionConfigurationStrategy} to be used by the
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java
index 09afd8df3eb7..06258e161e4c 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java
@@ -21,6 +21,8 @@
import java.util.List;
import java.util.Optional;
import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinPool.ForkJoinWorkerThreadFactory;
import java.util.concurrent.ForkJoinTask;
@@ -37,11 +39,16 @@
import org.junit.platform.commons.logging.LoggerFactory;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.engine.ConfigurationParameters;
+import org.junit.platform.engine.TestDescriptor.Type;
/**
* A {@link ForkJoinPool}-based
* {@linkplain HierarchicalTestExecutorService executor service} that executes
* {@linkplain TestTask test tasks} with the configured parallelism.
+ *
+ * Depending on {@linkplain ParallelExecutionConfiguration#getTestExecutor() the task executor},
+ * {@linkplain Type#TEST test tasks} can be executed in the same pool as other task types,
+ * or use a dedicated thread pool with a fixed number of threads.
*
* @since 1.3
* @see ForkJoinPool
@@ -50,8 +57,7 @@
@API(status = STABLE, since = "1.10")
public class ForkJoinPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService {
- private final ForkJoinPool forkJoinPool;
- private final int parallelism;
+ private final TestTaskSubmitter testTaskSubmitter;
/**
* Create a new {@code ForkJoinPoolHierarchicalTestExecutorService} based on
@@ -71,9 +77,12 @@ public ForkJoinPoolHierarchicalTestExecutorService(ConfigurationParameters confi
*/
@API(status = STABLE, since = "1.10")
public ForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) {
- forkJoinPool = createForkJoinPool(configuration);
- parallelism = forkJoinPool.getParallelism();
- LoggerFactory.getLogger(getClass()).config(() -> "Using ForkJoinPool with parallelism of " + parallelism);
+ if (configuration.getTestExecutor() == ParallelExecutionConfiguration.TestExecutor.FORK_JOIN) {
+ testTaskSubmitter = new ForkJoinPoolTestTaskSubmitter(configuration);
+ }
+ else {
+ testTaskSubmitter = new FixedThreadPoolTestTaskSubmitter(configuration);
+ }
}
private static ParallelExecutionConfiguration createConfiguration(ConfigurationParameters configurationParameters) {
@@ -82,7 +91,7 @@ private static ParallelExecutionConfiguration createConfiguration(ConfigurationP
return strategy.createConfiguration(configurationParameters);
}
- private ForkJoinPool createForkJoinPool(ParallelExecutionConfiguration configuration) {
+ private static ForkJoinPool createForkJoinPool(ParallelExecutionConfiguration configuration) {
ForkJoinWorkerThreadFactory threadFactory = new WorkerThreadFactory();
// Try to use constructor available in Java >= 9
Callable constructorInvocation = sinceJava9Constructor() //
@@ -112,38 +121,30 @@ private static Callable sinceJava7ConstructorInvocation(ParallelEx
return () -> new ForkJoinPool(configuration.getParallelism(), threadFactory, null, false);
}
- @Override
- public Future submit(TestTask testTask) {
- ExclusiveTask exclusiveTask = new ExclusiveTask(testTask);
- if (!isAlreadyRunningInForkJoinPool()) {
- // ensure we're running inside the ForkJoinPool so we
- // can use ForkJoinTask API in invokeAll etc.
- return forkJoinPool.submit(exclusiveTask);
+ @SuppressWarnings("try")
+ private static void executeWithLock(TestTask testTask) {
+ ResourceLock resourceLock = testTask.getResourceLock();
+ if (!(Thread.currentThread() instanceof ForkJoinWorkerThread)) {
+ resourceLock.release();
+ }
+
+ try (ResourceLock lock = resourceLock.acquire()) {
+ testTask.execute();
}
- // Limit the amount of queued work so we don't consume dynamic tests too eagerly
- // by forking only if the current worker thread's queue length is below the
- // desired parallelism. This optimistically assumes that the already queued tasks
- // can be stolen by other workers and the new task requires about the same
- // execution time as the already queued tasks. If the other workers are busy,
- // the parallelism is already at its desired level. If all already queued tasks
- // can be stolen by otherwise idle workers and the new task takes significantly
- // longer, parallelism will drop. However, that only happens if the enclosing test
- // task is the only one remaining which should rarely be the case.
- if (testTask.getExecutionMode() == CONCURRENT && ForkJoinTask.getSurplusQueuedTaskCount() < parallelism) {
- return exclusiveTask.fork();
+ catch (InterruptedException e) {
+ throw ExceptionUtils.throwAsUncheckedException(e);
}
- exclusiveTask.compute();
- return completedFuture(null);
}
- private boolean isAlreadyRunningInForkJoinPool() {
- return ForkJoinTask.getPool() == forkJoinPool;
+ @Override
+ public Future submit(TestTask testTask) {
+ return testTaskSubmitter.submit(testTask);
}
@Override
public void invokeAll(List extends TestTask> tasks) {
if (tasks.size() == 1) {
- new ExclusiveTask(tasks.get(0)).compute();
+ testTaskSubmitter.submitManaged(tasks.get(0)).get();
return;
}
Deque nonConcurrentTasks = new LinkedList<>();
@@ -158,8 +159,7 @@ private void forkConcurrentTasks(List extends TestTask> tasks, Deque tasks, Deque nonConcurrentTasks) {
for (ExclusiveTask task : nonConcurrentTasks) {
- task.compute();
+ testTaskSubmitter.invoke(task);
}
}
private void joinConcurrentTasksInReverseOrderToEnableWorkStealing(
Deque concurrentTasksInReverseOrder) {
for (ExclusiveTask forkedTask : concurrentTasksInReverseOrder) {
- forkedTask.join();
+ forkedTask.await();
}
}
@Override
public void close() {
- forkJoinPool.shutdownNow();
+ testTaskSubmitter.close();
+ }
+
+ private static class FixedThreadPoolTestTaskSubmitter extends ForkJoinPoolTestTaskSubmitter {
+ private final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
+ private final ExecutorService executorService;
+
+ FixedThreadPoolTestTaskSubmitter(ParallelExecutionConfiguration configuration) {
+ super(configuration);
+ executorService = Executors.newFixedThreadPool(configuration.getParallelism(), (r) -> {
+ Thread thread = new Thread(r);
+ thread.setContextClassLoader(contextClassLoader);
+ return thread;
+ });
+ LoggerFactory.getLogger(getClass()).config(
+ () -> "Executing tests with FixedThreadPool with parallelism of " + configuration.getParallelism());
+ }
+
+ @Override
+ public Future submit(TestTask testTask) {
+ if (!testTask.getType().isTest()) {
+ return super.submit(testTask);
+ }
+ else if (testTask.getExecutionMode() == CONCURRENT) {
+ return executorService.submit(() -> executeWithLock(testTask), null);
+ }
+ else {
+ // TODO what if this is called from a ForkJoinPool thread?
+ executeWithLock(testTask);
+ return completedFuture(null);
+ }
+ }
+
+ @Override
+ public ExclusiveTask submitConcurrent(ExclusiveTask exclusiveTask) {
+ TestTask testTask = exclusiveTask.testTask;
+ if (testTask.getType().isTest()) {
+ return new FixedThreadPoolExclusiveTask(executorService.submit(() -> executeWithLock(testTask), null));
+ }
+ else {
+ return super.submitConcurrent(exclusiveTask);
+ }
+ }
+
+ @Override
+ public void invoke(ExclusiveTask exclusiveTask) {
+ if (exclusiveTask.testTask.getType().isTest()) {
+ new ManagedFuture(executorService.submit(exclusiveTask::compute, null)).get();
+ }
+ else {
+ super.invoke(exclusiveTask);
+ }
+ }
+
+ @Override
+ public void close() {
+ super.close();
+ executorService.shutdownNow();
+ }
+ }
+
+ private static class ForkJoinPoolTestTaskSubmitter implements TestTaskSubmitter {
+
+ private final ForkJoinPool forkJoinPool;
+ private final int parallelism;
+
+ private ForkJoinPoolTestTaskSubmitter(ParallelExecutionConfiguration configuration) {
+ this.forkJoinPool = createForkJoinPool(configuration);
+ parallelism = forkJoinPool.getParallelism();
+ LoggerFactory.getLogger(getClass()).config(() -> "Using ForkJoinPool with parallelism of " + parallelism);
+ }
+
+ private boolean isAlreadyRunningInForkJoinPool() {
+ return ForkJoinTask.getPool() == forkJoinPool;
+ }
+
+ @Override
+ public Future submit(TestTask testTask) {
+ ExclusiveTask exclusiveTask = new ExclusiveTask(testTask);
+ if (!isAlreadyRunningInForkJoinPool()) {
+ // ensure we're running inside the ForkJoinPool so we
+ // can use ForkJoinTask API in invokeAll etc.
+ return forkJoinPool.submit(exclusiveTask);
+ }
+ // Limit the amount of queued work so we don't consume dynamic tests too eagerly
+ // by forking only if the current worker thread's queue length is below the
+ // desired parallelism. This optimistically assumes that the already queued tasks
+ // can be stolen by other workers and the new task requires about the same
+ // execution time as the already queued tasks. If the other workers are busy,
+ // the parallelism is already at its desired level. If all already queued tasks
+ // can be stolen by otherwise idle workers and the new task takes significantly
+ // longer, parallelism will drop. However, that only happens if the enclosing test
+ // task is the only one remaining which should rarely be the case.
+ if (testTask.getExecutionMode() == CONCURRENT && ForkJoinTask.getSurplusQueuedTaskCount() < parallelism) {
+ return exclusiveTask.fork();
+ }
+ exclusiveTask.compute();
+ return completedFuture(null);
+ }
+
+ @Override
+ public ManagedFuture submitManaged(TestTask testTask) {
+ return new ManagedFuture(submit(testTask));
+ }
+
+ @Override
+ public ExclusiveTask submitConcurrent(ExclusiveTask exclusiveTask) {
+ exclusiveTask.fork();
+ return exclusiveTask;
+ }
+
+ @Override
+ public void invoke(ExclusiveTask exclusiveTask) {
+ exclusiveTask.compute();
+ }
+
+ @Override
+ public void close() {
+ forkJoinPool.shutdownNow();
+ }
+ }
+
+ private interface TestTaskSubmitter {
+ Future submit(TestTask testTask);
+
+ ManagedFuture submitManaged(TestTask testTask);
+
+ ExclusiveTask submitConcurrent(ExclusiveTask exclusiveTask);
+
+ void invoke(ExclusiveTask exclusiveTask);
+
+ void close();
+ }
+
+ private static class ManagedFuture {
+ private final Future future;
+
+ ManagedFuture(Future future) {
+ this.future = future;
+ }
+
+ void get() {
+ try {
+ future.get();
+ }
+ catch (Exception e) {
+ future.cancel(true);
+ throw ExceptionUtils.throwAsUncheckedException(e);
+ }
+ }
+ }
+
+ @SuppressWarnings("serial")
+ static class FixedThreadPoolExclusiveTask extends ExclusiveTask {
+ private final Future future;
+
+ FixedThreadPoolExclusiveTask(Future future) {
+ super(null);
+ this.future = future;
+ }
+
+ @Override
+ public void compute() {
+ try {
+ future.get();
+ }
+ catch (Exception e) {
+ future.cancel(true);
+ throw ExceptionUtils.throwAsUncheckedException(e);
+ }
+ }
+
+ @Override
+ void await() {
+ compute();
+ }
}
// this class cannot not be serialized because TestTask is not Serializable
@@ -195,15 +372,13 @@ static class ExclusiveTask extends RecursiveAction {
this.testTask = testTask;
}
- @SuppressWarnings("try")
@Override
public void compute() {
- try (ResourceLock lock = testTask.getResourceLock().acquire()) {
- testTask.execute();
- }
- catch (InterruptedException e) {
- throw ExceptionUtils.throwAsUncheckedException(e);
- }
+ executeWithLock(testTask);
+ }
+
+ void await() {
+ join();
}
}
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java
index e69b5dc30330..a5f28815f321 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java
@@ -17,6 +17,7 @@
import org.apiguardian.api.API;
import org.junit.platform.engine.ExecutionRequest;
+import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode;
/**
@@ -84,6 +85,16 @@ public interface HierarchicalTestExecutorService extends AutoCloseable {
*/
interface TestTask {
+ /**
+ * @return the display name of this task.
+ */
+ String getDisplayName();
+
+ /**
+ * @return the {@link TestDescriptor.Type} of this task.
+ */
+ TestDescriptor.Type getType();
+
/**
* Get the {@linkplain ExecutionMode execution mode} of this task.
*/
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java
index e4d2208f2bc3..485f5312106a 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java
@@ -69,6 +69,16 @@ class NodeTestTask implements TestTask {
this.finalizer = finalizer;
}
+ @Override
+ public String getDisplayName() {
+ return testDescriptor.getDisplayName();
+ }
+
+ @Override
+ public TestDescriptor.Type getType() {
+ return testDescriptor.getType();
+ }
+
@Override
public ResourceLock getResourceLock() {
return taskContext.getExecutionAdvisor().getResourceLock(testDescriptor);
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionConfiguration.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionConfiguration.java
index 443434d7b8d2..78b8cde14ddc 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionConfiguration.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionConfiguration.java
@@ -13,6 +13,8 @@
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.apiguardian.api.API.Status.STABLE;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.function.Predicate;
@@ -34,10 +36,23 @@
@API(status = STABLE, since = "1.10")
public interface ParallelExecutionConfiguration {
+ /**
+ * Available test executors for different threading models.
+ *
+ * @see #getTestExecutor()
+ */
+ enum TestExecutor {
+
+ FORK_JOIN,
+
+ FIXED_THREADS
+ }
+
/**
* Get the parallelism to be used.
*
* @see ForkJoinPool#getParallelism()
+ * @see Executors#newFixedThreadPool(int)
*/
int getParallelism();
@@ -74,4 +89,18 @@ default Predicate super ForkJoinPool> getSaturatePredicate() {
return null;
}
+ /**
+ * Get the type of test executor to use. By default, a {@link ForkJoinPool} is used.
+ * Since this can cause conflicts with consumers that also rely on the fork-join framework,
+ * an {@link ExecutorService} with a fixed number of threads can also be used.
+ *
+ * @return The configured {@link TestExecutor}.
+ * @since 1.11
+ * @see Executors#newFixedThreadPool(int)
+ */
+ @API(status = EXPERIMENTAL, since = "1.11")
+ default TestExecutor getTestExecutor() {
+ return TestExecutor.FORK_JOIN;
+ }
+
}
diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategyTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategyTests.java
index dde62fa84c50..5cfa74b96ce0 100644
--- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategyTests.java
+++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategyTests.java
@@ -50,6 +50,20 @@ void fixedStrategyCreatesValidConfiguration() {
assertThat(configuration.getMaxPoolSize()).isEqualTo(256 + 42);
assertThat(configuration.getKeepAliveSeconds()).isEqualTo(30);
assertThat(configuration.getSaturatePredicate().test(null)).isTrue();
+ assertThat(configuration.getTestExecutor()).isSameAs(ParallelExecutionConfiguration.TestExecutor.FORK_JOIN);
+ }
+
+ @Test
+ void fixedStrategySupportsCustomTestExecutor() {
+ when(configParams.get(DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME))
+ .thenReturn(Optional.of("42"));
+ when(configParams.get(DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME))
+ .thenReturn(Optional.of("fixed_threads"));
+
+ ParallelExecutionConfigurationStrategy strategy = DefaultParallelExecutionConfigurationStrategy.FIXED;
+ var configuration = strategy.createConfiguration(configParams);
+
+ assertThat(configuration.getTestExecutor()).isSameAs(ParallelExecutionConfiguration.TestExecutor.FIXED_THREADS);
}
@Test
@@ -79,6 +93,18 @@ void dynamicStrategyCreatesValidConfiguration() {
assertThat(configuration.getMaxPoolSize()).isEqualTo(256 + (availableProcessors * 2));
assertThat(configuration.getKeepAliveSeconds()).isEqualTo(30);
assertThat(configuration.getSaturatePredicate().test(null)).isTrue();
+ assertThat(configuration.getTestExecutor()).isSameAs(ParallelExecutionConfiguration.TestExecutor.FORK_JOIN);
+ }
+
+ @Test
+ void dynamicStrategySupportsCustomTestExecutor() {
+ when(configParams.get(DefaultParallelExecutionConfigurationStrategy.CONFIG_DYNAMIC_TEST_EXECUTOR_PROPERTY_NAME))
+ .thenReturn(Optional.of("fixed_threads"));
+
+ ParallelExecutionConfigurationStrategy strategy = DefaultParallelExecutionConfigurationStrategy.DYNAMIC;
+ var configuration = strategy.createConfiguration(configParams);
+
+ assertThat(configuration.getTestExecutor()).isSameAs(ParallelExecutionConfiguration.TestExecutor.FIXED_THREADS);
}
@Test
@@ -212,7 +238,8 @@ void customStrategyThrowsExceptionWhenClassDoesNotExist() {
static class CustomParallelExecutionConfigurationStrategy implements ParallelExecutionConfigurationStrategy {
@Override
public ParallelExecutionConfiguration createConfiguration(ConfigurationParameters configurationParameters) {
- return new DefaultParallelExecutionConfiguration(1, 2, 3, 4, 5, __ -> true);
+ return new DefaultParallelExecutionConfiguration(1, 2, 3, 4, 5, __ -> true,
+ ParallelExecutionConfiguration.TestExecutor.FORK_JOIN);
}
}
diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/FixedThreadsParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/FixedThreadsParallelExecutionIntegrationTests.java
new file mode 100644
index 000000000000..40b959f49724
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/FixedThreadsParallelExecutionIntegrationTests.java
@@ -0,0 +1,844 @@
+/*
+ * 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.platform.engine.support.hierarchical;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.stream.Collectors.toList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.DynamicTest.dynamicTest;
+import static org.junit.jupiter.api.parallel.ExecutionMode.CONCURRENT;
+import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
+import static org.junit.jupiter.engine.Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME;
+import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE;
+import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME;
+import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME;
+import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME;
+import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME;
+import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request;
+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.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.started;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.assertj.core.api.Condition;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.MethodOrderer.MethodName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.RepeatedTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.TestReporter;
+import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.Isolated;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.platform.engine.TestDescriptor;
+import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.engine.reporting.ReportEntry;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.platform.testkit.engine.Event;
+
+class FixedThreadsParallelExecutionIntegrationTests {
+
+ @Test
+ void successfulParallelTest(TestReporter reporter) {
+ var events = executeConcurrently(3, SuccessfulParallelTestCase.class);
+
+ var startedTimestamps = getTimestampsFor(events, event(test(), started()));
+ var finishedTimestamps = getTimestampsFor(events, event(test(), finishedSuccessfully()));
+ 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(ThreadReporter.getThreadNames(events)).hasSize(3);
+ }
+
+ @Test
+ void failingTestWithoutLock() {
+ var events = executeConcurrently(3, FailingWithoutLockTestCase.class);
+ assertThat(events.stream().filter(event(test(), finishedWithFailure())::matches)).hasSize(2);
+ }
+
+ @Test
+ void successfulTestWithMethodLock() {
+ var events = executeConcurrently(3, SuccessfulWithMethodLockTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(3);
+ assertThat(ThreadReporter.getThreadNames(events)).hasSize(3);
+ }
+
+ @Test
+ void successfulTestWithClassLock() {
+ var events = executeConcurrently(3, SuccessfulWithClassLockTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(3);
+ assertThat(ThreadReporter.getThreadNames(events)).hasSize(1);
+ }
+
+ @Test
+ void testCaseWithFactory() {
+ var events = executeConcurrently(3, TestCaseWithTestFactory.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(3);
+ assertThat(ThreadReporter.getThreadNames(events)).hasSize(1);
+ }
+
+ @Test
+ void customContextClassLoader() {
+ var currentThread = Thread.currentThread();
+ var currentLoader = currentThread.getContextClassLoader();
+ var smilingLoader = new URLClassLoader("(-:", new URL[0], ClassLoader.getSystemClassLoader());
+ currentThread.setContextClassLoader(smilingLoader);
+ try {
+ var events = executeConcurrently(3, SuccessfulWithMethodLockTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(3);
+ assertThat(ThreadReporter.getThreadNames(events)).hasSize(3);
+ assertThat(ThreadReporter.getLoaderNames(events)).containsExactly("(-:");
+ }
+ finally {
+ currentThread.setContextClassLoader(currentLoader);
+ }
+ }
+
+ @RepeatedTest(10)
+ void mixingClassAndMethodLevelLocks() {
+ var events = executeConcurrently(4, TestCaseWithSortedLocks.class, TestCaseWithUnsortedLocks.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(6);
+ assertThat(ThreadReporter.getThreadNames(events).count()).isLessThanOrEqualTo(2);
+ }
+
+ @RepeatedTest(10)
+ void locksOnNestedTests() {
+ var events = executeConcurrently(3, TestCaseWithNestedLocks.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(6);
+ assertThat(ThreadReporter.getThreadNames(events)).hasSize(1);
+ }
+
+ @Test
+ void afterHooksAreCalledAfterConcurrentDynamicTestsAreFinished() {
+ var events = executeConcurrently(3, ConcurrentDynamicTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(1);
+ var timestampedEvents = ConcurrentDynamicTestCase.events;
+ assertThat(timestampedEvents.get("afterEach")).isAfterOrEqualTo(timestampedEvents.get("dynamicTestFinished"));
+ }
+
+ /**
+ * @since 1.4
+ * @see gh-1688
+ */
+ @Test
+ void threadInterruptedByUserCode() {
+ var events = executeConcurrently(3, InterruptedThreadTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(4);
+ }
+
+ @Test
+ void executesTestTemplatesWithResourceLocksInSameThread() {
+ var events = executeConcurrently(2, ConcurrentTemplateTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedSuccessfully())::matches)).hasSize(10);
+ assertThat(ThreadReporter.getThreadNames(events)).hasSize(1);
+ }
+
+ @Test
+ void executesClassesInParallelIfEnabledViaConfigurationParameter() {
+ ParallelClassesTestCase.GLOBAL_BARRIER.reset();
+
+ var configParams = Map.of(DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME, "concurrent");
+ var results = executeWithFixedParallelism(3, configParams, ParallelClassesTestCaseA.class,
+ ParallelClassesTestCaseB.class, ParallelClassesTestCaseC.class);
+
+ results.testEvents().assertStatistics(stats -> stats.succeeded(9));
+ assertThat(ThreadReporter.getThreadNames(results.allEvents().list())).hasSize(3);
+ var testClassA = findFirstTestDescriptor(results, container(ParallelClassesTestCaseA.class));
+ assertThat(ThreadReporter.getThreadNames(getEventsOfChildren(results, testClassA))).hasSize(1);
+ var testClassB = findFirstTestDescriptor(results, container(ParallelClassesTestCaseB.class));
+ assertThat(ThreadReporter.getThreadNames(getEventsOfChildren(results, testClassB))).hasSize(1);
+ var testClassC = findFirstTestDescriptor(results, container(ParallelClassesTestCaseC.class));
+ assertThat(ThreadReporter.getThreadNames(getEventsOfChildren(results, testClassC))).hasSize(1);
+ }
+
+ @Test
+ void executesMethodsInParallelIfEnabledViaConfigurationParameter() {
+ ParallelMethodsTestCase.barriersPerClass.clear();
+
+ var configParams = Map.of( //
+ DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent", //
+ DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME, "same_thread");
+ var results = executeWithFixedParallelism(3, configParams, ParallelMethodsTestCaseA.class,
+ ParallelMethodsTestCaseB.class, ParallelMethodsTestCaseC.class);
+
+ results.testEvents().assertStatistics(stats -> stats.succeeded(9));
+ assertThat(ThreadReporter.getThreadNames(results.allEvents().list())).hasSizeGreaterThanOrEqualTo(3);
+ var testClassA = findFirstTestDescriptor(results, container(ParallelMethodsTestCaseA.class));
+ assertThat(ThreadReporter.getThreadNames(getEventsOfChildren(results, testClassA))).hasSize(3);
+ var testClassB = findFirstTestDescriptor(results, container(ParallelMethodsTestCaseB.class));
+ assertThat(ThreadReporter.getThreadNames(getEventsOfChildren(results, testClassB))).hasSize(3);
+ var testClassC = findFirstTestDescriptor(results, container(ParallelMethodsTestCaseC.class));
+ assertThat(ThreadReporter.getThreadNames(getEventsOfChildren(results, testClassC))).hasSize(3);
+ }
+
+ @Test
+ void canRunTestsIsolatedFromEachOther() {
+ var events = executeConcurrently(2, IsolatedTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedWithFailure())::matches)).isEmpty();
+ }
+
+ @Test
+ void canRunTestsIsolatedFromEachOtherWithNestedCases() {
+ var events = executeConcurrently(4, NestedIsolatedTestCase.class);
+
+ assertThat(events.stream().filter(event(test(), finishedWithFailure())::matches)).isEmpty();
+ }
+
+ @Test
+ void canRunTestsIsolatedFromEachOtherAcrossClasses() {
+ var events = executeConcurrently(4, IndependentClasses.A.class, IndependentClasses.B.class);
+
+ assertThat(events.stream().filter(event(test(), finishedWithFailure())::matches)).isEmpty();
+ }
+
+ @RepeatedTest(10)
+ void canRunTestsIsolatedFromEachOtherAcrossClassesWithOtherResourceLocks() {
+ var events = executeConcurrently(4, IndependentClasses.B.class, IndependentClasses.C.class);
+
+ assertThat(events.stream().filter(event(test(), finishedWithFailure())::matches)).isEmpty();
+ }
+
+ @Isolated("testing")
+ static class IsolatedTestCase {
+ static AtomicInteger sharedResource;
+ static CountDownLatch countDownLatch;
+
+ @BeforeAll
+ static void initialize() {
+ sharedResource = new AtomicInteger();
+ countDownLatch = new CountDownLatch(2);
+ }
+
+ @Test
+ void a() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void b() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+
+ static class NestedIsolatedTestCase {
+ static AtomicInteger sharedResource;
+ static CountDownLatch countDownLatch;
+
+ @BeforeAll
+ static void initialize() {
+ sharedResource = new AtomicInteger();
+ countDownLatch = new CountDownLatch(6);
+ }
+
+ @Test
+ void a() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void b() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Nested
+ class Inner {
+
+ @Test
+ void a() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void b() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Nested
+ @Isolated
+ class InnerInner {
+
+ @Test
+ void a() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void b() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+ }
+ }
+
+ static class IndependentClasses {
+ static AtomicInteger sharedResource = new AtomicInteger();
+ static CountDownLatch countDownLatch = new CountDownLatch(4);
+
+ static class A {
+ @Test
+ void a() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void b() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+
+ @Isolated
+ static class B {
+ @Test
+ void a() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void b() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+
+ @ResourceLock("other")
+ static class C {
+ @Test
+ void a() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void b() throws Exception {
+ storeAndBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+ }
+
+ private List getEventsOfChildren(EngineExecutionResults results, TestDescriptor container) {
+ return results.testEvents().filter(
+ event -> event.getTestDescriptor().getParent().orElseThrow().equals(container)).collect(toList());
+ }
+
+ private TestDescriptor findFirstTestDescriptor(EngineExecutionResults results, Condition condition) {
+ return results.allEvents().filter(condition::matches).map(Event::getTestDescriptor).findFirst().orElseThrow();
+ }
+
+ private List getTimestampsFor(List events, Condition condition) {
+ // @formatter:off
+ return events.stream()
+ .filter(condition::matches)
+ .map(Event::getTimestamp)
+ .collect(toList());
+ // @formatter:on
+ }
+
+ private List executeConcurrently(int parallelism, Class>... testClasses) {
+ return executeWithFixedParallelism(parallelism, Map.of(DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent"),
+ testClasses).allEvents().list();
+ }
+
+ private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams,
+ Class>... testClasses) {
+ // @formatter:off
+ var discoveryRequest = request()
+ .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).collect(toList()))
+ .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, String.valueOf(true))
+ .configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed")
+ .configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, String.valueOf(parallelism))
+ .configurationParameter(PARALLEL_CONFIG_FIXED_TEST_EXECUTOR_PROPERTY_NAME, "fixed_threads")
+ .configurationParameters(configParams)
+ .build();
+ // @formatter:on
+ return EngineTestKit.execute("junit-jupiter", discoveryRequest);
+ }
+
+ // -------------------------------------------------------------------------
+
+ @ExtendWith(ThreadReporter.class)
+ static class SuccessfulParallelTestCase {
+
+ static AtomicInteger sharedResource;
+ static CountDownLatch countDownLatch;
+
+ @BeforeAll
+ static void initialize() {
+ sharedResource = new AtomicInteger();
+ countDownLatch = new CountDownLatch(3);
+ }
+
+ @Test
+ void firstTest() throws Exception {
+ incrementAndBlock(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void secondTest() throws Exception {
+ incrementAndBlock(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void thirdTest() throws Exception {
+ incrementAndBlock(sharedResource, countDownLatch);
+ }
+ }
+
+ @ExtendWith(ThreadReporter.class)
+ static class FailingWithoutLockTestCase {
+
+ static AtomicInteger sharedResource;
+ static CountDownLatch countDownLatch;
+
+ @BeforeAll
+ static void initialize() {
+ sharedResource = new AtomicInteger();
+ countDownLatch = new CountDownLatch(3);
+ }
+
+ @Test
+ void firstTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void secondTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void thirdTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+
+ @ExtendWith(ThreadReporter.class)
+ static class SuccessfulWithMethodLockTestCase {
+
+ static AtomicInteger sharedResource;
+ static CountDownLatch countDownLatch;
+
+ @BeforeAll
+ static void initialize() {
+ sharedResource = new AtomicInteger();
+ countDownLatch = new CountDownLatch(3);
+ }
+
+ @Test
+ @ResourceLock("sharedResource")
+ void firstTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ @ResourceLock("sharedResource")
+ void secondTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ @ResourceLock("sharedResource")
+ void thirdTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+
+ @ExtendWith(ThreadReporter.class)
+ @ResourceLock("sharedResource")
+ static class SuccessfulWithClassLockTestCase {
+
+ static AtomicInteger sharedResource;
+ static CountDownLatch countDownLatch;
+
+ @BeforeAll
+ static void initialize() {
+ sharedResource = new AtomicInteger();
+ countDownLatch = new CountDownLatch(3);
+ }
+
+ @Test
+ void firstTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void secondTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+
+ @Test
+ void thirdTest() throws Exception {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ }
+ }
+
+ static class TestCaseWithTestFactory {
+ @TestFactory
+ @Execution(SAME_THREAD)
+ Stream testFactory(TestReporter testReporter) {
+ var sharedResource = new AtomicInteger(0);
+ var countDownLatch = new CountDownLatch(3);
+ return IntStream.range(0, 3).mapToObj(i -> dynamicTest("test " + i, () -> {
+ incrementBlockAndCheck(sharedResource, countDownLatch);
+ testReporter.publishEntry("thread", Thread.currentThread().getName());
+ }));
+ }
+ }
+
+ private static final ReentrantLock A = new ReentrantLock();
+ private static final ReentrantLock B = new ReentrantLock();
+
+ @ExtendWith(ThreadReporter.class)
+ @ResourceLock("A")
+ static class TestCaseWithSortedLocks {
+ @ResourceLock("B")
+ @Test
+ void firstTest() {
+ assertTrue(A.tryLock());
+ assertTrue(B.tryLock());
+ }
+
+ @Execution(CONCURRENT)
+ @ResourceLock("B")
+ @Test
+ void secondTest() {
+ assertTrue(A.tryLock());
+ assertTrue(B.tryLock());
+ }
+
+ @ResourceLock("B")
+ @Test
+ void thirdTest() {
+ assertTrue(A.tryLock());
+ assertTrue(B.tryLock());
+ }
+
+ @AfterEach
+ void unlock() {
+ B.unlock();
+ A.unlock();
+ }
+ }
+
+ @ExtendWith(ThreadReporter.class)
+ @ResourceLock("B")
+ static class TestCaseWithUnsortedLocks {
+ @ResourceLock("A")
+ @Test
+ void firstTest() {
+ assertTrue(B.tryLock());
+ assertTrue(A.tryLock());
+ }
+
+ @Execution(CONCURRENT)
+ @ResourceLock("A")
+ @Test
+ void secondTest() {
+ assertTrue(B.tryLock());
+ assertTrue(A.tryLock());
+ }
+
+ @ResourceLock("A")
+ @Test
+ void thirdTest() {
+ assertTrue(B.tryLock());
+ assertTrue(A.tryLock());
+ }
+
+ @AfterEach
+ void unlock() {
+ A.unlock();
+ B.unlock();
+ }
+ }
+
+ @ExtendWith(ThreadReporter.class)
+ @ResourceLock("A")
+ static class TestCaseWithNestedLocks {
+
+ @ResourceLock("B")
+ @Test
+ void firstTest() {
+ assertTrue(A.tryLock());
+ assertTrue(B.tryLock());
+ }
+
+ @Execution(CONCURRENT)
+ @ResourceLock("B")
+ @Test
+ void secondTest() {
+ assertTrue(A.tryLock());
+ assertTrue(B.tryLock());
+ }
+
+ @Test
+ void thirdTest() {
+ assertTrue(A.tryLock());
+ assertTrue(B.tryLock());
+ }
+
+ @AfterEach
+ void unlock() {
+ A.unlock();
+ B.unlock();
+ }
+
+ @Nested
+ @ResourceLock("B")
+ class B {
+
+ @ResourceLock("A")
+ @Test
+ void firstTest() {
+ assertTrue(B.tryLock());
+ assertTrue(A.tryLock());
+ }
+
+ @ResourceLock("A")
+ @Test
+ void secondTest() {
+ assertTrue(B.tryLock());
+ assertTrue(A.tryLock());
+ }
+
+ @Test
+ void thirdTest() {
+ assertTrue(B.tryLock());
+ assertTrue(A.tryLock());
+ }
+ }
+ }
+
+ @Execution(CONCURRENT)
+ static class ConcurrentDynamicTestCase {
+ static Map events;
+
+ @BeforeAll
+ static void beforeAll() {
+ events = new ConcurrentHashMap<>();
+ }
+
+ @AfterEach
+ void afterEach() {
+ events.put("afterEach", Instant.now());
+ }
+
+ @TestFactory
+ DynamicTest testFactory() {
+ return dynamicTest("slow", () -> {
+ Thread.sleep(100);
+ events.put("dynamicTestFinished", Instant.now());
+ });
+ }
+ }
+
+ @TestMethodOrder(MethodName.class)
+ static class InterruptedThreadTestCase {
+
+ @Test
+ void test1() {
+ Thread.currentThread().interrupt();
+ }
+
+ @Test
+ void test2() throws InterruptedException {
+ Thread.sleep(10);
+ }
+
+ @Test
+ void test3() {
+ Thread.currentThread().interrupt();
+ }
+
+ @Test
+ void test4() throws InterruptedException {
+ Thread.sleep(10);
+ }
+
+ }
+
+ @Execution(CONCURRENT)
+ @ExtendWith(ThreadReporter.class)
+ static class ConcurrentTemplateTestCase {
+ @RepeatedTest(10)
+ @ResourceLock("a")
+ void repeatedTest() throws Exception {
+ Thread.sleep(100);
+ }
+ }
+
+ @ExtendWith(ThreadReporter.class)
+ static abstract class BarrierTestCase {
+
+ @Test
+ void test1() throws Exception {
+ getBarrier().await();
+ }
+
+ @Test
+ void test2() throws Exception {
+ getBarrier().await();
+ }
+
+ @Test
+ void test3() throws Exception {
+ getBarrier().await();
+ }
+
+ abstract CyclicBarrier getBarrier();
+
+ }
+
+ static class ParallelMethodsTestCase extends BarrierTestCase {
+
+ static final Map, CyclicBarrier> barriersPerClass = new ConcurrentHashMap<>();
+
+ @Override
+ CyclicBarrier getBarrier() {
+ return barriersPerClass.computeIfAbsent(this.getClass(), key -> new CyclicBarrier(3));
+ }
+ }
+
+ static class ParallelClassesTestCase extends BarrierTestCase {
+
+ static final CyclicBarrier GLOBAL_BARRIER = new CyclicBarrier(3);
+
+ @Override
+ CyclicBarrier getBarrier() {
+ return GLOBAL_BARRIER;
+ }
+
+ }
+
+ static class ParallelClassesTestCaseA extends ParallelClassesTestCase {
+ }
+
+ static class ParallelClassesTestCaseB extends ParallelClassesTestCase {
+ }
+
+ static class ParallelClassesTestCaseC extends ParallelClassesTestCase {
+ }
+
+ static class ParallelMethodsTestCaseA extends ParallelMethodsTestCase {
+ }
+
+ static class ParallelMethodsTestCaseB extends ParallelMethodsTestCase {
+ }
+
+ static class ParallelMethodsTestCaseC extends ParallelMethodsTestCase {
+ }
+
+ private static void incrementBlockAndCheck(AtomicInteger sharedResource, CountDownLatch countDownLatch)
+ throws InterruptedException {
+ var value = incrementAndBlock(sharedResource, countDownLatch);
+ assertEquals(value, sharedResource.get());
+ }
+
+ private static int incrementAndBlock(AtomicInteger sharedResource, CountDownLatch countDownLatch)
+ throws InterruptedException {
+ var value = sharedResource.incrementAndGet();
+ countDownLatch.countDown();
+ countDownLatch.await(estimateSimulatedTestDurationInMiliseconds(), MILLISECONDS);
+ return value;
+ }
+
+ private static void storeAndBlockAndCheck(AtomicInteger sharedResource, CountDownLatch countDownLatch)
+ throws InterruptedException {
+ var value = sharedResource.get();
+ countDownLatch.countDown();
+ countDownLatch.await(estimateSimulatedTestDurationInMiliseconds(), MILLISECONDS);
+ assertEquals(value, sharedResource.get());
+ }
+
+ /**
+ * To simulate tests running in parallel tests will modify a shared
+ * resource, simulate work by waiting, then check if the shared resource was
+ * not modified by any other thread.
+ *
+ * Depending on system performance the simulation of work needs to be longer
+ * on slower systems to ensure tests can run in parallel.
+ *
+ * Currently CI is known to be slow.
+ */
+ private static long estimateSimulatedTestDurationInMiliseconds() {
+ var runningInCi = Boolean.valueOf(System.getenv("CI"));
+ return runningInCi ? 1000 : 100;
+ }
+
+ static class ThreadReporter implements AfterTestExecutionCallback {
+
+ private static Stream getLoaderNames(List events) {
+ return getValues(events, "loader");
+ }
+
+ private static Stream getThreadNames(List events) {
+ return getValues(events, "thread");
+ }
+
+ private static Stream getValues(List events, String key) {
+ // @formatter:off
+ return events.stream()
+ .filter(type(REPORTING_ENTRY_PUBLISHED)::matches)
+ .map(event -> event.getPayload(ReportEntry.class).orElseThrow())
+ .map(ReportEntry::getKeyValuePairs)
+ .filter(keyValuePairs -> keyValuePairs.containsKey(key))
+ .map(keyValuePairs -> keyValuePairs.get(key))
+ .distinct();
+ // @formatter:on
+ }
+
+ @Override
+ public void afterTestExecution(ExtensionContext context) {
+ context.publishReportEntry("thread", Thread.currentThread().getName());
+ context.publishReportEntry("loader", Thread.currentThread().getContextClassLoader().getName());
+ }
+ }
+
+}