Skip to content

Commit 8ab24ee

Browse files
authored
Provide cancellation support for HierarchicalTestEngine implementations (#4729)
This adds support for cancellation to the JUnit Jupiter engine but also third-party implementations of `HierarchicalTestEngine` such as Spock and Cucumber. Issue: #4725
1 parent 6153c35 commit 8ab24ee

File tree

9 files changed

+268
-15
lines changed

9 files changed

+268
-15
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M2.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ repository on GitHub.
4141
all registered test engines. Please refer to the
4242
<<../user-guide/index.adoc#launcher-api-launcher-cancellation, User Guide>> for details
4343
and a usage example.
44+
* Provide cancellation support for implementations of `{HierarchicalTestEngine}` such as
45+
JUnit Jupiter, Spock, and Cucumber.
4446
* Introduce `TestTask.getTestDescriptor()` method for use in
4547
`HierarchicalTestExecutorService` implementations.
4648

documentation/src/docs/asciidoc/user-guide/advanced-topics/launcher-api.adoc

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,17 @@ include::{testDir}/example/UsingTheLauncherDemo.java[tags=cancellation]
375375
<4> Register the listener
376376
<5> Pass the `{LauncherExecutionRequest}` to `Launcher.execute`
377377

378-
WARNING: Cancelling tests relies on <<test-engines>> checking and responding to the
378+
[NOTE]
379+
.Test Engine Support for Cancellation
380+
====
381+
Cancelling tests relies on <<test-engines>> checking and responding to the
379382
`{CancellationToken}` appropriately (see
380383
<<test-engines-requirements-cancellation, Test Engine Requirements>> for details). The
381384
`Launcher` will also check the token and cancel test execution when multiple test engines
382385
are present at runtime.
383-
// TODO #4725 List engines that are known to support cancellation here
386+
387+
At the time of writing the following test engines support cancellation:
388+
389+
* `{junit-jupiter-engine}`
390+
* Any `{TestEngine}` extending `{HierarchicalTestEngine}` such as Spock and Cucumber
391+
====

junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestEngine.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import org.apiguardian.api.API;
1717
import org.junit.platform.commons.JUnitException;
18+
import org.junit.platform.engine.CancellationToken;
1819
import org.junit.platform.engine.ExecutionRequest;
1920
import org.junit.platform.engine.TestEngine;
2021

@@ -41,6 +42,9 @@ public HierarchicalTestEngine() {
4142
* its {@linkplain ExecutionRequest#getEngineExecutionListener() execution
4243
* listener} of test execution events.
4344
*
45+
* <p>Supports cancellation via the {@link CancellationToken} passed in the
46+
* supplied {@code request}.
47+
*
4448
* @see Node
4549
* @see #createExecutorService
4650
* @see #createExecutionContext
@@ -50,7 +54,6 @@ public final void execute(ExecutionRequest request) {
5054
try (HierarchicalTestExecutorService executorService = createExecutorService(request)) {
5155
C executionContext = createExecutionContext(request);
5256
ThrowableCollector.Factory throwableCollectorFactory = createThrowableCollectorFactory(request);
53-
// TODO #4725 Provide cancellation support for implementations of HierarchicalTestEngine
5457
new HierarchicalTestExecutor<>(request, executionContext, executorService,
5558
throwableCollectorFactory).execute().get();
5659
}

junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutor.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.util.concurrent.Future;
1414

1515
import org.jspecify.annotations.Nullable;
16+
import org.junit.platform.engine.CancellationToken;
1617
import org.junit.platform.engine.EngineExecutionListener;
1718
import org.junit.platform.engine.ExecutionRequest;
1819
import org.junit.platform.engine.TestDescriptor;
@@ -48,14 +49,23 @@ class HierarchicalTestExecutor<C extends EngineExecutionContext> {
4849
}
4950

5051
Future<@Nullable Void> execute() {
52+
return this.executorService.submit(createRootTestTask());
53+
}
54+
55+
private NodeTestTask<C> createRootTestTask() {
56+
NodeTestTaskContext taskContext = createTaskContext();
5157
TestDescriptor rootTestDescriptor = this.request.getRootTestDescriptor();
52-
EngineExecutionListener executionListener = this.request.getEngineExecutionListener();
53-
NodeExecutionAdvisor executionAdvisor = new NodeTreeWalker().walk(rootTestDescriptor);
54-
NodeTestTaskContext taskContext = new NodeTestTaskContext(executionListener, this.executorService,
55-
this.throwableCollectorFactory, executionAdvisor);
5658
NodeTestTask<C> rootTestTask = new NodeTestTask<>(taskContext, rootTestDescriptor);
5759
rootTestTask.setParentContext(this.rootContext);
58-
return this.executorService.submit(rootTestTask);
60+
return rootTestTask;
61+
}
62+
63+
private NodeTestTaskContext createTaskContext() {
64+
EngineExecutionListener executionListener = this.request.getEngineExecutionListener();
65+
NodeExecutionAdvisor executionAdvisor = new NodeTreeWalker().walk(this.request.getRootTestDescriptor());
66+
CancellationToken cancellationToken = this.request.getCancellationToken();
67+
return new NodeTestTaskContext(executionListener, this.executorService, this.throwableCollectorFactory,
68+
executionAdvisor, cancellationToken);
5969
}
6070

6171
}

junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ class NodeTestTask<C extends EngineExecutionContext> implements TestTask {
4949
private static final Runnable NOOP = () -> {
5050
};
5151

52+
static final SkipResult CANCELLED_SKIP_RESULT = SkipResult.skip("Execution cancelled");
53+
5254
private final NodeTestTaskContext taskContext;
5355
private final TestDescriptor testDescriptor;
5456
private final Node<C> node;
@@ -104,9 +106,11 @@ void setParentContext(@Nullable C parentContext) {
104106
public void execute() {
105107
try {
106108
throwableCollector = taskContext.throwableCollectorFactory().create();
107-
prepare();
109+
if (!taskContext.cancellationToken().isCancellationRequested()) {
110+
prepare();
111+
}
108112
if (throwableCollector.isEmpty()) {
109-
checkWhetherSkipped();
113+
throwableCollector.execute(() -> skipResult = checkWhetherSkipped());
110114
}
111115
if (throwableCollector.isEmpty() && !requiredSkipResult().isSkipped()) {
112116
executeRecursively();
@@ -144,8 +148,10 @@ private void prepare() {
144148
parentContext = null;
145149
}
146150

147-
private void checkWhetherSkipped() {
148-
requiredThrowableCollector().execute(() -> skipResult = node.shouldBeSkipped(requiredContext()));
151+
private SkipResult checkWhetherSkipped() throws Exception {
152+
return taskContext.cancellationToken().isCancellationRequested() //
153+
? CANCELLED_SKIP_RESULT //
154+
: node.shouldBeSkipped(requiredContext());
149155
}
150156

151157
private void executeRecursively() {
@@ -193,7 +199,7 @@ private void reportCompletion() {
193199
if (throwableCollector.isEmpty() && requiredSkipResult().isSkipped()) {
194200
var skipResult = requiredSkipResult();
195201
try {
196-
node.nodeSkipped(requiredContext(), testDescriptor, skipResult);
202+
node.nodeSkipped(requireNonNullElse(context, parentContext), testDescriptor, skipResult);
197203
}
198204
catch (Throwable throwable) {
199205
UnrecoverableExceptions.rethrowIfUnrecoverable(throwable);

junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTaskContext.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,22 @@
1010

1111
package org.junit.platform.engine.support.hierarchical;
1212

13+
import org.junit.platform.engine.CancellationToken;
1314
import org.junit.platform.engine.EngineExecutionListener;
1415

1516
/**
1617
* @since 1.3.1
1718
*/
1819
record NodeTestTaskContext(EngineExecutionListener listener, HierarchicalTestExecutorService executorService,
19-
ThrowableCollector.Factory throwableCollectorFactory, NodeExecutionAdvisor executionAdvisor) {
20+
ThrowableCollector.Factory throwableCollectorFactory, NodeExecutionAdvisor executionAdvisor,
21+
CancellationToken cancellationToken) {
2022

2123
NodeTestTaskContext withListener(EngineExecutionListener listener) {
2224
if (this.listener == listener) {
2325
return this;
2426
}
25-
return new NodeTestTaskContext(listener, executorService, throwableCollectorFactory, executionAdvisor);
27+
return new NodeTestTaskContext(listener, executorService, throwableCollectorFactory, executionAdvisor,
28+
cancellationToken);
2629
}
2730

2831
}

jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ protected EngineDiscoveryResults discoverTests(LauncherDiscoveryRequest request)
9797
return EngineTestKit.discover(this.engine, request);
9898
}
9999

100+
protected EngineTestKit.Builder jupiterTestEngine() {
101+
return EngineTestKit.engine(this.engine) //
102+
.outputDirectoryProvider(dummyOutputDirectoryProvider()) //
103+
.configurationParameter(STACKTRACE_PRUNING_ENABLED_PROPERTY_NAME, String.valueOf(false)) //
104+
.configurationParameter(CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME, Severity.INFO.name()) //
105+
.enableImplicitConfigurationParameters(false);
106+
}
107+
100108
protected static LauncherDiscoveryRequestBuilder defaultRequest() {
101109
return request() //
102110
.outputDirectoryProvider(dummyOutputDirectoryProvider()) //
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.jupiter.engine;
12+
13+
import static java.util.Objects.requireNonNull;
14+
import static org.junit.jupiter.api.Assertions.fail;
15+
import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;
16+
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
17+
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
18+
import static org.junit.platform.testkit.engine.EventConditions.container;
19+
import static org.junit.platform.testkit.engine.EventConditions.displayName;
20+
import static org.junit.platform.testkit.engine.EventConditions.dynamicTestRegistered;
21+
import static org.junit.platform.testkit.engine.EventConditions.engine;
22+
import static org.junit.platform.testkit.engine.EventConditions.event;
23+
import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
24+
import static org.junit.platform.testkit.engine.EventConditions.reportEntry;
25+
import static org.junit.platform.testkit.engine.EventConditions.skippedWithReason;
26+
import static org.junit.platform.testkit.engine.EventConditions.started;
27+
import static org.junit.platform.testkit.engine.EventConditions.test;
28+
29+
import java.util.Map;
30+
import java.util.stream.Stream;
31+
32+
import org.jspecify.annotations.Nullable;
33+
import org.junit.jupiter.api.AfterEach;
34+
import org.junit.jupiter.api.BeforeEach;
35+
import org.junit.jupiter.api.DynamicNode;
36+
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
37+
import org.junit.jupiter.api.Order;
38+
import org.junit.jupiter.api.Test;
39+
import org.junit.jupiter.api.TestFactory;
40+
import org.junit.jupiter.api.TestMethodOrder;
41+
import org.junit.jupiter.api.TestReporter;
42+
import org.junit.platform.engine.CancellationToken;
43+
44+
class ExecutionCancellationTests extends AbstractJupiterTestEngineTests {
45+
46+
@BeforeEach
47+
void initializeCancellationToken() {
48+
TestCase.cancellationToken = CancellationToken.create();
49+
}
50+
51+
@AfterEach
52+
void resetCancellationToken() {
53+
TestCase.cancellationToken = null;
54+
}
55+
56+
@Test
57+
void canCancelExecutionWhileTestClassIsRunning() {
58+
var testClass = RegularTestCase.class;
59+
60+
var results = jupiterTestEngine() //
61+
.selectors(selectClass(testClass)) //
62+
.cancellationToken(TestCase.requiredCancellationToken()) //
63+
.execute();
64+
65+
results.testEvents().assertStatistics(stats -> stats.started(1).finished(1).skipped(1));
66+
67+
results.allEvents().assertEventsMatchExactly( //
68+
event(engine(), started()), //
69+
event(container(testClass), started()), //
70+
event(test("first"), started()), //
71+
event(test("first"), reportEntry(Map.of("cancelled", "true"))), //
72+
event(test("first"), finishedSuccessfully()), //
73+
event(test("second"), skippedWithReason("Execution cancelled")), //
74+
event(container(testClass), finishedSuccessfully()), //
75+
event(engine(), finishedSuccessfully()));
76+
}
77+
78+
@Test
79+
void canCancelExecutionWhileDynamicTestsAreRunning() {
80+
var testClass = DynamicTestCase.class;
81+
82+
var results = jupiterTestEngine() //
83+
.selectors(selectClass(testClass)) //
84+
.cancellationToken(TestCase.requiredCancellationToken()) //
85+
.execute();
86+
87+
results.containerEvents().assertStatistics(stats -> stats.skipped(1));
88+
results.testEvents().assertStatistics(stats -> stats.started(1).finished(1).skipped(0));
89+
90+
results.allEvents().assertEventsMatchExactly( //
91+
event(engine(), started()), //
92+
event(container(testClass), started()), //
93+
event(container("testFactory"), started()), //
94+
event(dynamicTestRegistered("#1"), displayName("first")), //
95+
event(test("#1"), started()), //
96+
event(test("#1"), finishedSuccessfully()), //
97+
event(dynamicTestRegistered("#2"), displayName("container")), //
98+
event(container("#2"), skippedWithReason("Execution cancelled")), //
99+
event(container("testFactory"), finishedSuccessfully()), //
100+
event(container(testClass), finishedSuccessfully()), //
101+
event(engine(), finishedSuccessfully()));
102+
}
103+
104+
static class TestCase {
105+
106+
static @Nullable CancellationToken cancellationToken;
107+
108+
static CancellationToken requiredCancellationToken() {
109+
return requireNonNull(cancellationToken);
110+
}
111+
112+
}
113+
114+
@SuppressWarnings("JUnitMalformedDeclaration")
115+
@TestMethodOrder(OrderAnnotation.class)
116+
static class RegularTestCase extends TestCase {
117+
118+
@Test
119+
@Order(1)
120+
void first() {
121+
requiredCancellationToken().cancel();
122+
}
123+
124+
@AfterEach
125+
void afterEach(TestReporter reporter) {
126+
reporter.publishEntry("cancelled", String.valueOf(requiredCancellationToken().isCancellationRequested()));
127+
}
128+
129+
@Test
130+
@Order(2)
131+
void second() {
132+
fail("should not be called");
133+
}
134+
}
135+
136+
static class DynamicTestCase extends TestCase {
137+
138+
@TestFactory
139+
Stream<DynamicNode> testFactory() {
140+
return Stream.of( //
141+
dynamicTest("first", () -> requiredCancellationToken().cancel()), //
142+
dynamicContainer("container", Stream.of( //
143+
dynamicTest("second", () -> fail("should not be called")) //
144+
)) //
145+
);
146+
}
147+
}
148+
149+
}

0 commit comments

Comments
 (0)