diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M2.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M2.adoc index 90b3be039cfe..f6f48e5bbea4 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M2.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M2.adoc @@ -41,6 +41,8 @@ repository on GitHub. all registered test engines. Please refer to the <<../user-guide/index.adoc#launcher-api-launcher-cancellation, User Guide>> for details and a usage example. +* Provide cancellation support for implementations of `{HierarchicalTestEngine}` such as + JUnit Jupiter, Spock, and Cucumber. * Introduce `TestTask.getTestDescriptor()` method for use in `HierarchicalTestExecutorService` implementations. diff --git a/documentation/src/docs/asciidoc/user-guide/advanced-topics/launcher-api.adoc b/documentation/src/docs/asciidoc/user-guide/advanced-topics/launcher-api.adoc index b2b87db51a2b..35c8fe642cf5 100644 --- a/documentation/src/docs/asciidoc/user-guide/advanced-topics/launcher-api.adoc +++ b/documentation/src/docs/asciidoc/user-guide/advanced-topics/launcher-api.adoc @@ -375,9 +375,17 @@ include::{testDir}/example/UsingTheLauncherDemo.java[tags=cancellation] <4> Register the listener <5> Pass the `{LauncherExecutionRequest}` to `Launcher.execute` -WARNING: Cancelling tests relies on <> checking and responding to the +[NOTE] +.Test Engine Support for Cancellation +==== +Cancelling tests relies on <> checking and responding to the `{CancellationToken}` appropriately (see <> for details). The `Launcher` will also check the token and cancel test execution when multiple test engines are present at runtime. -// TODO #4725 List engines that are known to support cancellation here + +At the time of writing the following test engines support cancellation: + +* `{junit-jupiter-engine}` +* Any `{TestEngine}` extending `{HierarchicalTestEngine}` such as Spock and Cucumber +==== diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestEngine.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestEngine.java index f49f0cbcef12..c68193d13bb5 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestEngine.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestEngine.java @@ -15,6 +15,7 @@ import org.apiguardian.api.API; import org.junit.platform.commons.JUnitException; +import org.junit.platform.engine.CancellationToken; import org.junit.platform.engine.ExecutionRequest; import org.junit.platform.engine.TestEngine; @@ -41,6 +42,9 @@ public HierarchicalTestEngine() { * its {@linkplain ExecutionRequest#getEngineExecutionListener() execution * listener} of test execution events. * + *

Supports cancellation via the {@link CancellationToken} passed in the + * supplied {@code request}. + * * @see Node * @see #createExecutorService * @see #createExecutionContext @@ -50,7 +54,6 @@ public final void execute(ExecutionRequest request) { try (HierarchicalTestExecutorService executorService = createExecutorService(request)) { C executionContext = createExecutionContext(request); ThrowableCollector.Factory throwableCollectorFactory = createThrowableCollectorFactory(request); - // TODO #4725 Provide cancellation support for implementations of HierarchicalTestEngine new HierarchicalTestExecutor<>(request, executionContext, executorService, throwableCollectorFactory).execute().get(); } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutor.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutor.java index 68549dbf632a..9fb2ac27e2c9 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutor.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutor.java @@ -13,6 +13,7 @@ import java.util.concurrent.Future; import org.jspecify.annotations.Nullable; +import org.junit.platform.engine.CancellationToken; import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.ExecutionRequest; import org.junit.platform.engine.TestDescriptor; @@ -48,14 +49,23 @@ class HierarchicalTestExecutor { } Future<@Nullable Void> execute() { + return this.executorService.submit(createRootTestTask()); + } + + private NodeTestTask createRootTestTask() { + NodeTestTaskContext taskContext = createTaskContext(); TestDescriptor rootTestDescriptor = this.request.getRootTestDescriptor(); - EngineExecutionListener executionListener = this.request.getEngineExecutionListener(); - NodeExecutionAdvisor executionAdvisor = new NodeTreeWalker().walk(rootTestDescriptor); - NodeTestTaskContext taskContext = new NodeTestTaskContext(executionListener, this.executorService, - this.throwableCollectorFactory, executionAdvisor); NodeTestTask rootTestTask = new NodeTestTask<>(taskContext, rootTestDescriptor); rootTestTask.setParentContext(this.rootContext); - return this.executorService.submit(rootTestTask); + return rootTestTask; + } + + private NodeTestTaskContext createTaskContext() { + EngineExecutionListener executionListener = this.request.getEngineExecutionListener(); + NodeExecutionAdvisor executionAdvisor = new NodeTreeWalker().walk(this.request.getRootTestDescriptor()); + CancellationToken cancellationToken = this.request.getCancellationToken(); + return new NodeTestTaskContext(executionListener, this.executorService, this.throwableCollectorFactory, + executionAdvisor, cancellationToken); } } 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 6fb309dbab65..321c3df0027b 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 @@ -49,6 +49,8 @@ class NodeTestTask implements TestTask { private static final Runnable NOOP = () -> { }; + static final SkipResult CANCELLED_SKIP_RESULT = SkipResult.skip("Execution cancelled"); + private final NodeTestTaskContext taskContext; private final TestDescriptor testDescriptor; private final Node node; @@ -104,9 +106,11 @@ void setParentContext(@Nullable C parentContext) { public void execute() { try { throwableCollector = taskContext.throwableCollectorFactory().create(); - prepare(); + if (!taskContext.cancellationToken().isCancellationRequested()) { + prepare(); + } if (throwableCollector.isEmpty()) { - checkWhetherSkipped(); + throwableCollector.execute(() -> skipResult = checkWhetherSkipped()); } if (throwableCollector.isEmpty() && !requiredSkipResult().isSkipped()) { executeRecursively(); @@ -144,8 +148,10 @@ private void prepare() { parentContext = null; } - private void checkWhetherSkipped() { - requiredThrowableCollector().execute(() -> skipResult = node.shouldBeSkipped(requiredContext())); + private SkipResult checkWhetherSkipped() throws Exception { + return taskContext.cancellationToken().isCancellationRequested() // + ? CANCELLED_SKIP_RESULT // + : node.shouldBeSkipped(requiredContext()); } private void executeRecursively() { @@ -193,7 +199,7 @@ private void reportCompletion() { if (throwableCollector.isEmpty() && requiredSkipResult().isSkipped()) { var skipResult = requiredSkipResult(); try { - node.nodeSkipped(requiredContext(), testDescriptor, skipResult); + node.nodeSkipped(requireNonNullElse(context, parentContext), testDescriptor, skipResult); } catch (Throwable throwable) { UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTaskContext.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTaskContext.java index 005d8ab6d02c..d5dbc34c5e09 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTaskContext.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTaskContext.java @@ -10,19 +10,22 @@ package org.junit.platform.engine.support.hierarchical; +import org.junit.platform.engine.CancellationToken; import org.junit.platform.engine.EngineExecutionListener; /** * @since 1.3.1 */ record NodeTestTaskContext(EngineExecutionListener listener, HierarchicalTestExecutorService executorService, - ThrowableCollector.Factory throwableCollectorFactory, NodeExecutionAdvisor executionAdvisor) { + ThrowableCollector.Factory throwableCollectorFactory, NodeExecutionAdvisor executionAdvisor, + CancellationToken cancellationToken) { NodeTestTaskContext withListener(EngineExecutionListener listener) { if (this.listener == listener) { return this; } - return new NodeTestTaskContext(listener, executorService, throwableCollectorFactory, executionAdvisor); + return new NodeTestTaskContext(listener, executorService, throwableCollectorFactory, executionAdvisor, + cancellationToken); } } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java index 8b6b7c5ae009..02d084aa65be 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java @@ -97,6 +97,14 @@ protected EngineDiscoveryResults discoverTests(LauncherDiscoveryRequest request) return EngineTestKit.discover(this.engine, request); } + protected EngineTestKit.Builder jupiterTestEngine() { + return EngineTestKit.engine(this.engine) // + .outputDirectoryProvider(dummyOutputDirectoryProvider()) // + .configurationParameter(STACKTRACE_PRUNING_ENABLED_PROPERTY_NAME, String.valueOf(false)) // + .configurationParameter(CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME, Severity.INFO.name()) // + .enableImplicitConfigurationParameters(false); + } + protected static LauncherDiscoveryRequestBuilder defaultRequest() { return request() // .outputDirectoryProvider(dummyOutputDirectoryProvider()) // diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/ExecutionCancellationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/ExecutionCancellationTests.java new file mode 100644 index 000000000000..319f6d63b398 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/ExecutionCancellationTests.java @@ -0,0 +1,149 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine; + +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.DynamicContainer.dynamicContainer; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.container; +import static org.junit.platform.testkit.engine.EventConditions.displayName; +import static org.junit.platform.testkit.engine.EventConditions.dynamicTestRegistered; +import static org.junit.platform.testkit.engine.EventConditions.engine; +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.reportEntry; +import static org.junit.platform.testkit.engine.EventConditions.skippedWithReason; +import static org.junit.platform.testkit.engine.EventConditions.started; +import static org.junit.platform.testkit.engine.EventConditions.test; + +import java.util.Map; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +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.platform.engine.CancellationToken; + +class ExecutionCancellationTests extends AbstractJupiterTestEngineTests { + + @BeforeEach + void initializeCancellationToken() { + TestCase.cancellationToken = CancellationToken.create(); + } + + @AfterEach + void resetCancellationToken() { + TestCase.cancellationToken = null; + } + + @Test + void canCancelExecutionWhileTestClassIsRunning() { + var testClass = RegularTestCase.class; + + var results = jupiterTestEngine() // + .selectors(selectClass(testClass)) // + .cancellationToken(TestCase.requiredCancellationToken()) // + .execute(); + + results.testEvents().assertStatistics(stats -> stats.started(1).finished(1).skipped(1)); + + results.allEvents().assertEventsMatchExactly( // + event(engine(), started()), // + event(container(testClass), started()), // + event(test("first"), started()), // + event(test("first"), reportEntry(Map.of("cancelled", "true"))), // + event(test("first"), finishedSuccessfully()), // + event(test("second"), skippedWithReason("Execution cancelled")), // + event(container(testClass), finishedSuccessfully()), // + event(engine(), finishedSuccessfully())); + } + + @Test + void canCancelExecutionWhileDynamicTestsAreRunning() { + var testClass = DynamicTestCase.class; + + var results = jupiterTestEngine() // + .selectors(selectClass(testClass)) // + .cancellationToken(TestCase.requiredCancellationToken()) // + .execute(); + + results.containerEvents().assertStatistics(stats -> stats.skipped(1)); + results.testEvents().assertStatistics(stats -> stats.started(1).finished(1).skipped(0)); + + results.allEvents().assertEventsMatchExactly( // + event(engine(), started()), // + event(container(testClass), started()), // + event(container("testFactory"), started()), // + event(dynamicTestRegistered("#1"), displayName("first")), // + event(test("#1"), started()), // + event(test("#1"), finishedSuccessfully()), // + event(dynamicTestRegistered("#2"), displayName("container")), // + event(container("#2"), skippedWithReason("Execution cancelled")), // + event(container("testFactory"), finishedSuccessfully()), // + event(container(testClass), finishedSuccessfully()), // + event(engine(), finishedSuccessfully())); + } + + static class TestCase { + + static @Nullable CancellationToken cancellationToken; + + static CancellationToken requiredCancellationToken() { + return requireNonNull(cancellationToken); + } + + } + + @SuppressWarnings("JUnitMalformedDeclaration") + @TestMethodOrder(OrderAnnotation.class) + static class RegularTestCase extends TestCase { + + @Test + @Order(1) + void first() { + requiredCancellationToken().cancel(); + } + + @AfterEach + void afterEach(TestReporter reporter) { + reporter.publishEntry("cancelled", String.valueOf(requiredCancellationToken().isCancellationRequested())); + } + + @Test + @Order(2) + void second() { + fail("should not be called"); + } + } + + static class DynamicTestCase extends TestCase { + + @TestFactory + Stream testFactory() { + return Stream.of( // + dynamicTest("first", () -> requiredCancellationToken().cancel()), // + dynamicContainer("container", Stream.of( // + dynamicTest("second", () -> fail("should not be called")) // + )) // + ); + } + } + +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java index b97839efc8c6..2602b22e5d5f 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java @@ -38,6 +38,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.function.ThrowingConsumer; +import org.junit.platform.engine.CancellationToken; import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.ExecutionRequest; import org.junit.platform.engine.TestDescriptor; @@ -69,6 +70,8 @@ class HierarchicalTestExecutorTests { @Mock EngineExecutionListener listener; + CancellationToken cancellationToken = CancellationToken.create(); + MyEngineExecutionContext rootContext = new MyEngineExecutionContext(); HierarchicalTestExecutor executor; @@ -82,6 +85,7 @@ private HierarchicalTestExecutor createExecutor( ExecutionRequest request = mock(); when(request.getRootTestDescriptor()).thenReturn(root); when(request.getEngineExecutionListener()).thenReturn(listener); + when(request.getCancellationToken()).thenReturn(cancellationToken); return new HierarchicalTestExecutor<>(request, rootContext, executorService, OpenTest4JAwareThrowableCollector::new); } @@ -707,6 +711,66 @@ void exceptionInAfterIsReportedInsteadOfEarlierTestAbortedException() throws Exc exceptionInAfter).hasSuppressedException(exceptionInExecute); } + @Test + void reportsNodeAsSkippedWhenCancelledPriorToExecution() { + + var child = spy(new MyLeaf(UniqueId.root("leaf", "child container"))); + root.addChild(child); + + cancellationToken.cancel(); + executor.execute(); + + var inOrder = inOrder(listener, root, child); + inOrder.verify(root).nodeSkipped(rootContext, root, NodeTestTask.CANCELLED_SKIP_RESULT); + inOrder.verify(listener).executionSkipped(root, NodeTestTask.CANCELLED_SKIP_RESULT.getReason().orElseThrow()); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void reportsNodeAsSkippedWhenCancelledDuringPrepare() throws Exception { + + var child = spy(new MyLeaf(UniqueId.root("leaf", "child container"))); + root.addChild(child); + + when(root.prepare(any())).thenAnswer(invocation -> { + cancellationToken.cancel(); + return invocation.callRealMethod(); + }); + + executor.execute(); + + var inOrder = inOrder(listener, root, child); + inOrder.verify(root).prepare(rootContext); + inOrder.verify(root).nodeSkipped(rootContext, root, NodeTestTask.CANCELLED_SKIP_RESULT); + inOrder.verify(listener).executionSkipped(root, NodeTestTask.CANCELLED_SKIP_RESULT.getReason().orElseThrow()); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void reportsNodeAsSkippedWhenCancelledDuringBefore() throws Exception { + + var child = spy(new MyLeaf(UniqueId.root("leaf", "child container"))); + root.addChild(child); + + when(root.before(any())).thenAnswer(invocation -> { + cancellationToken.cancel(); + return invocation.callRealMethod(); + }); + + executor.execute(); + + var inOrder = inOrder(listener, root, child); + inOrder.verify(listener).executionStarted(root); + inOrder.verify(root).before(any()); + inOrder.verify(root).execute(any(), any()); + inOrder.verify(child).nodeSkipped(any(), eq(child), eq(NodeTestTask.CANCELLED_SKIP_RESULT)); + inOrder.verify(listener).executionSkipped(child, NodeTestTask.CANCELLED_SKIP_RESULT.getReason().orElseThrow()); + inOrder.verify(root).after(any()); + inOrder.verify(root).cleanUp(any()); + inOrder.verify(listener).executionFinished(root, TestExecutionResult.successful()); + inOrder.verifyNoMoreInteractions(); + } + // ------------------------------------------------------------------- private static class MyEngineExecutionContext implements EngineExecutionContext {