From 784cfc238b19befaedbe6c5d5b73508b40f19626 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Sat, 31 Aug 2024 09:10:29 +0200 Subject: [PATCH] Add thread-per-test-class execution model The thread-per-test-class execution model runs tests via one thread per test class. This model is primarily useful in situations when test executions attach `ThreadLocal`s to the test worker thread, which "leak" into other test class executions. This problem can lead to out-of-memory errors, if the `ThreadLocal`s reference (large) object trees, either as its value or via its initial-value `Supplier`, which cannot be cleaned up / garbage collected, because the `ThreadLocal` is referenced by the test worker thread. The problem becomes even worse, if the test class creates its own class loader and attaches `ThreadLocal`s that reference classes loaded by such class loaders. In such cases the whole class loader including all its loaded classes and (static) state is not eligible for garbage collection, leaking more heap (and non-heap) memory. Using one thread per test class works around the above problem(s), because once the (ephemeral) thread per test-class finishes, the whole thread and all its `ThreadLocal`s become eligible for garbage collection. Particularly Quarkus unit tests (`@QuarkusTest` annotated test classes) benefit from this execution model. This change cannot eliminate other sources of similar leaks, like threads spawned from tests or not removed MBeans. Those kinds of leaks are better handled by the test code or the tested code providing "proper" cleanup mechanisms. This new execution is implemented via the introduced `ThreadPerClassHierarchicalTestExecutorService`, a 3rd model in addition to `SameThreadHierarchicalTestExecutorService` and `ForkJoinPoolHierarchicalTestExecutorService`. It is enabled if `junit.jupiter.execution.threadperclass.enabled` is set to `true` and `junit.jupiter.execution.parallel.enabled` is `false`. Issue: #3939 --- .../release-notes-5.12.0-M1.adoc | 2 +- .../asciidoc/user-guide/writing-tests.adoc | 23 ++ .../jupiter/engine/JupiterTestEngine.java | 7 + .../config/CachingJupiterConfiguration.java | 6 + .../config/DefaultJupiterConfiguration.java | 5 + .../engine/config/JupiterConfiguration.java | 3 + .../support/hierarchical/NodeTestTask.java | 4 + ...rClassHierarchicalTestExecutorService.java | 133 +++++++++ .../ThreadPerClassTestExecutorTests.java | 274 ++++++++++++++++++ 9 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassHierarchicalTestExecutorService.java create mode 100644 platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassTestExecutorTests.java diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc index 89a1753a5280..4f5261e5bfbf 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc @@ -26,7 +26,7 @@ JUnit repository on GitHub. [[release-notes-5.12.0-M1-junit-platform-new-features-and-improvements]] ==== New Features and Improvements -* ❓ +* Introduce thread-per-test-class execution model. [[release-notes-5.12.0-M1-junit-jupiter]] diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 49a5d4ff3f9b..13ff0303ec02 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -2963,6 +2963,29 @@ include::{testDir}/example/SharedResourcesDemo.java[tags=user_guide] ---- +[[writing-tests-isolated-execution]] +=== Thread-per-class Isolated Execution + +By default, JUnit Jupiter tests are run sequentially from a single thread. The +thread-per-class isolated execution, set `junit.jupiter.execution.threadperclass.enabled` +to `true`. Each test class will be executed in its own thread. + +The thread-per-class execution model is useful, if test classes need to ensure that +per-thread resources, for example instances of `ThreadLocal`, do not leak to other test +classes. This becomes relevant if 3rd party libraries provide no way to clean up the +`ThreadLocal` instances they created. If such a `ThreadLocal` references a class that has +been loaded via a different class loader, this can lead to class-leaks and eventually +out-of-memory errors. Running test classes using the thread-per-class execution model allows +the JVM to eventually garbage collect those `ThreadLocal` instances and prevent such +out-of-memory errors. + +`junit.jupiter.execution.threadperclass.enabled` is only evaluated, if +`junit.jupiter.execution.parallel.enabled` is `false`. + +Since every test class requires a new thread to be created and requires some synchronization, +execution with the thread-per-class model has a little overhead. + + [[writing-tests-built-in-extensions]] === Built-in Extensions diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 94b13afd4548..1f71df39e3f9 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -30,6 +30,7 @@ import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; +import org.junit.platform.engine.support.hierarchical.ThreadPerClassHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; /** @@ -74,9 +75,15 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { + if (configuration.isThreadPerClassExecutionEnabled()) { + throw new IllegalArgumentException("Parallel execution and thread-per-class is not supported"); + } return new ForkJoinPoolHierarchicalTestExecutorService(new PrefixedConfigurationParameters( request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); } + if (configuration.isThreadPerClassExecutionEnabled()) { + return new ThreadPerClassHierarchicalTestExecutorService(request.getConfigurationParameters()); + } return super.createExecutorService(request); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java index 1280c4b12a11..a560d8169d08 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java @@ -61,6 +61,12 @@ public boolean isParallelExecutionEnabled() { key -> delegate.isParallelExecutionEnabled()); } + @Override + public boolean isThreadPerClassExecutionEnabled() { + return (boolean) cache.computeIfAbsent(THREAD_PER_CLASS_EXECUTION_ENABLED_PROPERTY_NAME, + key -> delegate.isThreadPerClassExecutionEnabled()); + } + @Override public boolean isExtensionAutoDetectionEnabled() { return (boolean) cache.computeIfAbsent(EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME, diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 83ef7592534f..34791cdd34d4 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -84,6 +84,11 @@ public boolean isParallelExecutionEnabled() { return configurationParameters.getBoolean(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME).orElse(false); } + @Override + public boolean isThreadPerClassExecutionEnabled() { + return configurationParameters.getBoolean(THREAD_PER_CLASS_EXECUTION_ENABLED_PROPERTY_NAME).orElse(false); + } + @Override public boolean isExtensionAutoDetectionEnabled() { return configurationParameters.getBoolean(EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME).orElse(false); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 7a90072363d2..3810ea11b74b 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -36,6 +36,7 @@ public interface JupiterConfiguration { String DEACTIVATE_CONDITIONS_PATTERN_PROPERTY_NAME = "junit.jupiter.conditions.deactivate"; String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; + String THREAD_PER_CLASS_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.threadperclass.enabled"; String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; String EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.enabled"; @@ -50,6 +51,8 @@ public interface JupiterConfiguration { boolean isParallelExecutionEnabled(); + boolean isThreadPerClassExecutionEnabled(); + boolean isExtensionAutoDetectionEnabled(); ExecutionMode getDefaultExecutionMode(); 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..b6e701883466 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,10 @@ class NodeTestTask implements TestTask { this.finalizer = finalizer; } + TestDescriptor getTestDescriptor() { + return testDescriptor; + } + @Override public ResourceLock getResourceLock() { return taskContext.getExecutionAdvisor().getResourceLock(testDescriptor); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassHierarchicalTestExecutorService.java new file mode 100644 index 000000000000..411326d49488 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassHierarchicalTestExecutorService.java @@ -0,0 +1,133 @@ +/* + * 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.lang.String.format; +import static java.time.Duration.ofMinutes; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.apiguardian.api.API.Status.STABLE; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apiguardian.api.API; +import org.junit.platform.commons.JUnitException; +import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.UniqueId; + +/** + * A {@linkplain HierarchicalTestExecutorService executor service} that creates a new thread for + * each test class, all {@linkplain TestTask test tasks}. + * + *

This execution model is useful to prevent some kinds of class / class-loader leaks. For + * example, if a test creates {@link ClassLoader}s and the tests or any of the code and libraries + * create {@link ThreadLocal}s, those thread locals would accumulate in the single {@link + * SameThreadHierarchicalTestExecutorService} causing a class-(loader)-leak. + * + * @since 5.12 + */ +@API(status = STABLE, since = "5.12") +public class ThreadPerClassHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + + private final AtomicInteger threadCount = new AtomicInteger(); + private final Duration interruptWaitDuration; + + static final Duration DEFAULT_INTERRUPT_WAIT_DURATION = ofMinutes(5); + static final String THREAD_PER_CLASS_INTERRUPTED_WAIT_TIME_SECONDS = "junit.jupiter.execution.threadperclass.interrupted.waittime.seconds"; + + public ThreadPerClassHierarchicalTestExecutorService(ConfigurationParameters config) { + interruptWaitDuration = config.get(THREAD_PER_CLASS_INTERRUPTED_WAIT_TIME_SECONDS).map(Integer::parseInt).map( + Duration::ofSeconds).orElse(DEFAULT_INTERRUPT_WAIT_DURATION); + } + + @Override + public Future submit(TestTask testTask) { + executeTask(testTask); + return completedFuture(null); + } + + @Override + public void invokeAll(List tasks) { + tasks.forEach(this::executeTask); + } + + protected void executeTask(TestTask testTask) { + NodeTestTask nodeTestTask = (NodeTestTask) testTask; + TestDescriptor testDescriptor = nodeTestTask.getTestDescriptor(); + + UniqueId.Segment lastSegment = testDescriptor.getUniqueId().getLastSegment(); + + if ("class".equals(lastSegment.getType())) { + executeOnDifferentThread(testTask, lastSegment); + } + else { + testTask.execute(); + } + } + + private void executeOnDifferentThread(TestTask testTask, UniqueId.Segment lastSegment) { + CompletableFuture future = new CompletableFuture<>(); + Thread threadPerClass = new Thread(() -> { + try { + testTask.execute(); + future.complete(null); + } + catch (Exception e) { + future.completeExceptionally(e); + } + }, threadName(lastSegment)); + threadPerClass.setDaemon(true); + threadPerClass.start(); + + try { + try { + future.get(); + } + catch (InterruptedException e) { + // propagate a thread-interrupt to the executing class + threadPerClass.interrupt(); + try { + future.get(interruptWaitDuration.toMillis(), MILLISECONDS); + } + catch (InterruptedException ie) { + threadPerClass.interrupt(); + } + catch (TimeoutException to) { + throw new JUnitException(format("Test class %s was interrupted but did not terminate within %s", + lastSegment.getValue(), interruptWaitDuration), to); + } + } + } + catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new JUnitException("TestTask execution failure", cause); + } + } + + private String threadName(UniqueId.Segment lastSegment) { + return format("TEST THREAD #%d FOR %s", threadCount.incrementAndGet(), lastSegment.getValue()); + } + + @Override + public void close() { + // nothing to do + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassTestExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassTestExecutorTests.java new file mode 100644 index 000000000000..9926446b27db --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassTestExecutorTests.java @@ -0,0 +1,274 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.platform.engine.TestExecutionResult.Status.FAILED; +import static org.junit.platform.engine.TestExecutionResult.Status.SUCCESSFUL; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.EngineExecutionListener; +import org.junit.platform.engine.ExecutionRequest; +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.engine.UniqueId; +import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Micro-tests that verify behavior of {@link HierarchicalTestExecutor}. + * + * @since 1.0 + */ +@ExtendWith(MockitoExtension.class) +class ThreadPerClassTestExecutorTests { + + @Spy + MyContainer root = new MyContainer(UniqueId.root("container", "root")); + + @Mock + EngineExecutionListener listener; + + MyEngineExecutionContext rootContext = new MyEngineExecutionContext(); + HierarchicalTestExecutor executor; + + Duration testMaxWaitTime = Duration.ofMinutes(5); + + @BeforeEach + void init() { + executor = createExecutor( + new ThreadPerClassHierarchicalTestExecutorService(new EmptyConfigurationParameters())); + } + + private HierarchicalTestExecutor createExecutor( + HierarchicalTestExecutorService executorService) { + var request = new ExecutionRequest(root, listener, null); + return new HierarchicalTestExecutor<>(request, rootContext, executorService, + OpenTest4JAwareThrowableCollector::new); + } + + @Test + void failures() throws Exception { + var rootId = UniqueId.root("engine", "my engine"); + var container = spy(new MyContainer(rootId)); + root.addChild(container); + + var clazzId1 = rootId.append("class", "my.Class"); + var clazz1 = spy(new MyContainer(clazzId1)); + container.addChild(clazz1); + var failure1 = new AssertionError("something went wrong"); + var thread1 = new AtomicReference(); + when(clazz1.execute(any(), any())).then(inv -> { + thread1.set(Thread.currentThread()); + throw failure1; + }); + + var clazzId2 = rootId.append("class", "my.OtherClass"); + var clazz2 = spy(new MyContainer(clazzId2)); + container.addChild(clazz2); + var failure2 = new AssertionError("something went wrong"); + var thread2 = new AtomicReference(); + when(clazz2.execute(any(), any())).then(inv -> { + thread2.set(Thread.currentThread()); + throw failure2; + }); + + executor.execute().get(); + + assertThat(thread1.get()).isNotNull().isNotSameAs(Thread.currentThread()).extracting(Thread::getName, + STRING).startsWith("TEST THREAD ").contains(" FOR my.Class"); + assertThat(thread1.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread1.get().isAlive()).isFalse(); + assertThat(thread1.get().isDaemon()).isTrue(); + + assertThat(thread2.get()).isNotNull().isNotSameAs(Thread.currentThread()).isNotSameAs(thread1.get()).extracting( + Thread::getName, STRING).startsWith("TEST THREAD ").contains(" FOR my.OtherClass"); + assertThat(thread2.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread2.get().isAlive()).isFalse(); + assertThat(thread2.get().isDaemon()).isTrue(); + + var rootExecutionResult = ArgumentCaptor.forClass(TestExecutionResult.class); + verify(listener).executionFinished(eq(clazz1), rootExecutionResult.capture()); + assertThat(rootExecutionResult.getValue().getStatus()).isEqualTo(FAILED); + assertThat(rootExecutionResult.getValue().getThrowable()).get().isInstanceOf(AssertionError.class).asInstanceOf( + type(AssertionError.class)).extracting(AssertionError::getMessage).isEqualTo("something went wrong"); + } + + @Test + void goodResults() throws Exception { + var rootId = UniqueId.root("engine", "my engine"); + var container = spy(new MyContainer(rootId)); + root.addChild(container); + + var clazzId1 = rootId.append("class", "my.Class"); + var clazz1 = spy(new MyContainer(clazzId1)); + container.addChild(clazz1); + var thread1 = new AtomicReference(); + when(clazz1.execute(any(), any())).then(inv -> { + thread1.set(Thread.currentThread()); + return inv.callRealMethod(); + }); + + var clazzId2 = rootId.append("class", "my.OtherClass"); + var clazz2 = spy(new MyContainer(clazzId2)); + container.addChild(clazz2); + var failure2 = new AssertionError("something went wrong"); + var thread2 = new AtomicReference(); + when(clazz2.execute(any(), any())).then(inv -> { + thread2.set(Thread.currentThread()); + throw failure2; + }); + + executor.execute().get(); + + assertThat(thread1.get()).isNotNull().isNotSameAs(Thread.currentThread()).extracting(Thread::getName, + STRING).startsWith("TEST THREAD ").contains(" FOR my.Class"); + assertThat(thread1.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread1.get().isAlive()).isFalse(); + assertThat(thread1.get().isDaemon()).isTrue(); + + assertThat(thread2.get()).isNotNull().isNotSameAs(Thread.currentThread()).isNotSameAs(thread1.get()).extracting( + Thread::getName, STRING).startsWith("TEST THREAD ").contains(" FOR my.OtherClass"); + assertThat(thread2.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread2.get().isAlive()).isFalse(); + assertThat(thread2.get().isDaemon()).isTrue(); + + var rootExecutionResult = ArgumentCaptor.forClass(TestExecutionResult.class); + verify(listener).executionFinished(eq(clazz1), rootExecutionResult.capture()); + assertThat(rootExecutionResult.getValue().getStatus()).isEqualTo(SUCCESSFUL); + assertThat(rootExecutionResult.getValue().getThrowable()).isEmpty(); + } + + /** Check that {@code Thread.interrupt()} is propagated to the test execution. */ + @Test + void interrupt() throws Exception { + var rootId = UniqueId.root("engine", "my engine"); + var container = spy(new MyContainer(rootId)); + root.addChild(container); + var clazzId = rootId.append("class", "my.Class"); + var clazz = spy(new MyContainer(clazzId)); + container.addChild(clazz); + var thread = new AtomicReference(); + + // latch to wait for that "our test" is currently running + var sleeping = new CountDownLatch(1); + var interruptedHandled = new CountDownLatch(1); + + when(clazz.execute(any(), any())).then(inv -> { + thread.set(Thread.currentThread()); + try { + sleeping.countDown(); + Thread.sleep(testMaxWaitTime); + } + catch (InterruptedException e) { + interruptedHandled.countDown(); + throw new RuntimeException(e); + } + return fail(); + }); + + Future future; + try (var executorService = Executors.newSingleThreadExecutor()) { + future = executorService.submit(() -> executor.execute().get()); + + // wait until "our test" is running + assertThat(sleeping.await(testMaxWaitTime.toMillis(), MILLISECONDS)).isTrue(); + + // Interrupt the executor + future.cancel(true); + + // wait for the "test execution" to finish and being interrupted + assertThatThrownBy(() -> future.get(testMaxWaitTime.toMillis(), MILLISECONDS)).isInstanceOf( + CancellationException.class); + + assertThat(interruptedHandled.await(testMaxWaitTime.toMillis(), MILLISECONDS)).isTrue(); + + assertThat(thread.get()).isNotNull().isNotSameAs(Thread.currentThread()).extracting(Thread::getName, + STRING).startsWith("TEST THREAD ").contains(" FOR my.Class"); + assertThat(thread.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread.get().isAlive()).isFalse(); + assertThat(thread.get().isDaemon()).isTrue(); + + var rootExecutionResult = ArgumentCaptor.forClass(TestExecutionResult.class); + verify(listener).executionFinished(eq(clazz), rootExecutionResult.capture()); + assertThat(rootExecutionResult.getValue().getStatus()).isEqualTo(FAILED); + assertThat(rootExecutionResult.getValue().getThrowable()).get().isInstanceOf( + RuntimeException.class).extracting(Throwable::getCause).isInstanceOf(InterruptedException.class); + } + } + + // ------------------------------------------------------------------- + + private static class MyEngineExecutionContext implements EngineExecutionContext { + } + + private static class MyContainer extends AbstractTestDescriptor implements Node { + + MyContainer(UniqueId uniqueId) { + super(uniqueId, uniqueId.toString()); + } + + @Override + public Type getType() { + return Type.CONTAINER; + } + } + + private static class EmptyConfigurationParameters implements ConfigurationParameters { + @Override + public Optional get(String key) { + return Optional.empty(); + } + + @Override + public Optional getBoolean(String key) { + return Optional.empty(); + } + + @Override + @SuppressWarnings("deprecation") + public int size() { + return 0; + } + + @Override + public Set keySet() { + return Collections.emptySet(); + } + + } +}