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 saturate; + private final TestExecutor testExecutor; DefaultParallelExecutionConfiguration(int parallelism, int minimumRunnable, int maxPoolSize, int corePoolSize, - int keepAliveSeconds, Predicate saturate) { + int keepAliveSeconds, Predicate 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 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 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 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 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()); + } + } + +}