Skip to content

Commit 79c36ac

Browse files
committed
Introduce support for cancelling a running execution to the Launcher API
This commit introduces `CancellationToken` that can be created and passed to the `Launcher` as part of a `LauncherExecutionRequest`. The `Launcher` checks whether cancellation has been requested on the token prior to asking each test engine to execute tests. If cancellation has been requested, the `Launcher` reports all direct children of engine descriptors as skipped. Moreover, it passes the `CancellationToken` to each engine so they can check and respond to cancellation as well which will be implemented separately as indicated by TODO comments. `EngineTestKit` also supports passing a `CancellationToken` to the engine under test. The documentation now contains an example for implementing a fail-fast listener and documents the additional requirement for test engines that wish to support cancellation. Resolves #1880.
1 parent f3a2cd0 commit 79c36ac

File tree

23 files changed

+489
-83
lines changed

23 files changed

+489
-83
lines changed

documentation/src/docs/asciidoc/link-attributes.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ endif::[]
2222
// Platform Engine
2323
:junit-platform-engine: {javadoc-root}/org.junit.platform.engine/org/junit/platform/engine/package-summary.html[junit-platform-engine]
2424
:junit-platform-engine-support-discovery: {javadoc-root}/org.junit.platform.engine/org/junit/platform/engine/support/discovery/package-summary.html[org.junit.platform.engine.support.discovery]
25+
:CancellationToken: {javadoc-root}/org.junit.platform.engine/org/junit/platform/engine/CancellationToken.html[CancellationToken]
2526
:ClasspathResourceSelector: {javadoc-root}/org.junit.platform.engine/org/junit/platform/engine/discovery/ClasspathResourceSelector.html[ClasspathResourceSelector]
2627
:ClasspathRootSelector: {javadoc-root}/org.junit.platform.engine/org/junit/platform/engine/discovery/ClasspathRootSelector.html[ClasspathRootSelector]
2728
:ClassSelector: {javadoc-root}/org.junit.platform.engine/org/junit/platform/engine/discovery/ClassSelector.html[ClassSelector]
@@ -66,6 +67,7 @@ endif::[]
6667
:LauncherDiscoveryListener: {javadoc-root}/org.junit.platform.launcher/org/junit/platform/launcher/LauncherDiscoveryListener.html[LauncherDiscoveryListener]
6768
:LauncherDiscoveryRequest: {javadoc-root}/org.junit.platform.launcher/org/junit/platform/launcher/LauncherDiscoveryRequest.html[LauncherDiscoveryRequest]
6869
:LauncherDiscoveryRequestBuilder: {javadoc-root}/org.junit.platform.launcher/org/junit/platform/launcher/core/LauncherDiscoveryRequestBuilder.html[LauncherDiscoveryRequestBuilder]
70+
:LauncherExecutionRequest: {javadoc-root}/org.junit.platform.launcher/org/junit/platform/launcher/LauncherExecutionRequest.html[LauncherExecutionRequest]
6971
:LauncherFactory: {javadoc-root}/org.junit.platform.launcher/org/junit/platform/launcher/core/LauncherFactory.html[LauncherFactory]
7072
:LauncherInterceptor: {javadoc-root}/org.junit.platform.launcher/org/junit/platform/launcher/LauncherInterceptor.html[LauncherInterceptor]
7173
:LauncherSession: {javadoc-root}/org.junit.platform.launcher/org/junit/platform/launcher/LauncherSession.html[LauncherSession]

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ compatibility with build tools and IDEs:
120120
siblings or other nodes that are required for the execution of the selected tests.
121121
* `TestEngines` _should_ support <<running-tests-tags, tagging>> tests and containers so
122122
that tag filters can be applied when discovering tests.
123+
* [[test-engines-requirements-cancellation]] A `TestEngine` _should_ cancel its execution
124+
when the `{CancellationToken}` it is passed as part of the `ExecutionRequest` indicates
125+
that cancellation has been requested. In this case, it _should_ report any remaining
126+
`TestDescriptors` as skipped but not report any events for their descendants. It _may_
127+
report already started `TestDescriptors` as aborted in case they have not been executed
128+
completely. If a `TestEngine` supports cancellation, it should clean up any resources
129+
that it has created just like if execution had finished regularly.
123130

124131
[[test-engines-discovery-issues]]
125132
==== Reporting Discovery Issues

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ Usage Example:
3030

3131
[source,java,indent=0]
3232
----
33-
include::{testDir}/example/UsingTheLauncherDemo.java[tags=imports]
33+
include::{testDir}/example/UsingTheLauncherForDiscoveryDemo.java[tags=imports]
3434
----
3535

3636
[source,java,indent=0]
3737
----
38-
include::{testDir}/example/UsingTheLauncherDemo.java[tags=discovery]
38+
include::{testDir}/example/UsingTheLauncherForDiscoveryDemo.java[tags=discovery]
3939
----
4040

4141
You can select classes, methods, and all classes in a package or even search for all tests
@@ -353,3 +353,31 @@ between them.
353353

354354
Alternatively, it's possible to inject resources into test engines by
355355
<<launcher-api-launcher-session-listeners-custom, registering a `LauncherSessionListener`>>.
356+
357+
[[launcher-api-launcher-cancellation]]
358+
==== Cancelling a Running Test Execution
359+
360+
The launcher API provides the ability to cancel a running test execution mid-flight while
361+
allowing engines to clean up resources. To request an execution to be cancelled, you need
362+
to call `cancel()` on the `{CancellationToken}` that is passed to `Launcher.execute` as
363+
part of the `{LauncherExecutionRequest}`.
364+
365+
For example, implementing a listener that cancels test execution after the first test
366+
failed can be achieved as follows.
367+
368+
[source,java,indent=0]
369+
----
370+
include::{testDir}/example/UsingTheLauncherDemo.java[tags=cancellation]
371+
----
372+
<1> Create a `{CancellationToken}`
373+
<2> Implement a `{TestExecutionListener}` that calls `cancel()` when a test fails
374+
<3> Register the cancellation token
375+
<4> Register the listener
376+
<5> Pass the `{LauncherExecutionRequest}` to `Launcher.execute`
377+
378+
WARNING: Cancelling tests relies on <<test-engines>> checking and responding to the
379+
`{CancellationToken}` appropriately (see
380+
<<test-engines-requirements-cancellation, Test Engine Requirements>> for details). The
381+
`Launcher` will also check the token and cancel test execution when multiple test engines
382+
are present at runtime.
383+
// TODO #4725 List engines that are known to support cancellation here

documentation/src/test/java/example/UsingTheLauncherDemo.java

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@
1111
package example;
1212

1313
// tag::imports[]
14+
import static org.junit.platform.engine.TestExecutionResult.Status.FAILED;
1415
import static org.junit.platform.engine.discovery.ClassNameFilter.includeClassNamePatterns;
1516
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
1617
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectPackage;
1718

1819
import java.io.PrintWriter;
1920
import java.nio.file.Path;
2021

22+
import org.junit.platform.engine.CancellationToken;
2123
import org.junit.platform.engine.FilterResult;
2224
import org.junit.platform.engine.TestDescriptor;
25+
import org.junit.platform.engine.TestExecutionResult;
2326
import org.junit.platform.launcher.Launcher;
2427
import org.junit.platform.launcher.LauncherDiscoveryListener;
2528
import org.junit.platform.launcher.LauncherDiscoveryRequest;
@@ -28,6 +31,7 @@
2831
import org.junit.platform.launcher.LauncherSessionListener;
2932
import org.junit.platform.launcher.PostDiscoveryFilter;
3033
import org.junit.platform.launcher.TestExecutionListener;
34+
import org.junit.platform.launcher.TestIdentifier;
3135
import org.junit.platform.launcher.TestPlan;
3236
import org.junit.platform.launcher.core.LauncherConfig;
3337
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
@@ -43,33 +47,6 @@
4347
*/
4448
class UsingTheLauncherDemo {
4549

46-
@org.junit.jupiter.api.Test
47-
@SuppressWarnings("unused")
48-
void discovery() {
49-
// @formatter:off
50-
// tag::discovery[]
51-
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
52-
.selectors(
53-
selectPackage("com.example.mytests"),
54-
selectClass(MyTestClass.class)
55-
)
56-
.filters(
57-
includeClassNamePatterns(".*Tests")
58-
)
59-
// end::discovery[]
60-
.configurationParameter("enableHttpServer", "false")
61-
// tag::discovery[]
62-
.build();
63-
64-
try (LauncherSession session = LauncherFactory.openSession()) {
65-
TestPlan testPlan = session.getLauncher().discover(request);
66-
67-
// ... discover additional test plans or execute tests
68-
}
69-
// end::discovery[]
70-
// @formatter:on
71-
}
72-
7350
@org.junit.jupiter.api.Tag("exclude")
7451
@org.junit.jupiter.api.Test
7552
@SuppressWarnings("unused")
@@ -142,6 +119,41 @@ void launcherConfig() {
142119
// @formatter:on
143120
}
144121

122+
@org.junit.jupiter.api.Test
123+
@SuppressWarnings("unused")
124+
void cancellation() {
125+
// tag::cancellation[]
126+
CancellationToken cancellationToken = CancellationToken.create(); // <1>
127+
128+
TestExecutionListener failFastListener = new TestExecutionListener() {
129+
@Override
130+
public void executionFinished(TestIdentifier identifier, TestExecutionResult result) {
131+
if (result.getStatus() == FAILED) {
132+
cancellationToken.cancel(); // <2>
133+
}
134+
}
135+
};
136+
137+
// end::cancellation[]
138+
// @formatter:off
139+
// tag::cancellation[]
140+
LauncherExecutionRequest executionRequest = LauncherDiscoveryRequestBuilder.request()
141+
.selectors(selectClass(MyTestClass.class))
142+
.forExecution()
143+
.cancellationToken(cancellationToken) // <3>
144+
.listeners(failFastListener) // <4>
145+
.build();
146+
// end::cancellation[]
147+
// @formatter:off
148+
// tag::cancellation[]
149+
150+
try (LauncherSession session = LauncherFactory.openSession()) {
151+
session.getLauncher().execute(executionRequest); // <5>
152+
}
153+
// end::cancellation[]
154+
// @formatter:on
155+
}
156+
145157
}
146158

147159
class MyTestClass {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 example;
12+
13+
// tag::imports[]
14+
import static org.junit.platform.engine.discovery.ClassNameFilter.includeClassNamePatterns;
15+
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
16+
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectPackage;
17+
18+
import org.junit.platform.launcher.LauncherDiscoveryRequest;
19+
import org.junit.platform.launcher.LauncherSession;
20+
import org.junit.platform.launcher.TestPlan;
21+
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
22+
import org.junit.platform.launcher.core.LauncherFactory;
23+
// end::imports[]
24+
25+
/**
26+
* @since 6.0
27+
*/
28+
class UsingTheLauncherForDiscoveryDemo {
29+
30+
@org.junit.jupiter.api.Test
31+
@SuppressWarnings("unused")
32+
void discovery() {
33+
// @formatter:off
34+
// tag::discovery[]
35+
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
36+
.selectors(
37+
selectPackage("com.example.mytests"),
38+
selectClass(MyTestClass.class)
39+
)
40+
.filters(
41+
includeClassNamePatterns(".*Tests")
42+
)
43+
.build();
44+
45+
try (LauncherSession session = LauncherFactory.openSession()) {
46+
TestPlan testPlan = session.getLauncher().discover(request);
47+
48+
// ... discover additional test plans or execute tests
49+
}
50+
// end::discovery[]
51+
// @formatter:on
52+
}
53+
54+
static class MyTestClass {
55+
}
56+
57+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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.platform.engine;
12+
13+
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
14+
15+
import org.apiguardian.api.API;
16+
17+
/**
18+
* Token that should be checked to determine whether an operation was requested
19+
* to be cancelled.
20+
*
21+
* <p>For example, this is used by the
22+
* {@link org.junit.platform.launcher.Launcher} and
23+
* {@link org.junit.platform.engine.TestEngine} implementations to determine
24+
* whether the current test execution should be cancelled.
25+
*
26+
* <p>This interface is not intended to be implemented by clients.
27+
*
28+
* @since 6.0
29+
* @see org.junit.platform.launcher.LauncherExecutionRequest#getCancellationToken()
30+
* @see ExecutionRequest#getCancellationToken()
31+
*/
32+
@API(status = EXPERIMENTAL, since = "6.0")
33+
public sealed interface CancellationToken permits RegularCancellationToken, DisabledCancellationToken {
34+
35+
/**
36+
* Create a new, uncancelled cancellation token.
37+
*/
38+
static CancellationToken create() {
39+
return new RegularCancellationToken();
40+
}
41+
42+
/**
43+
* Get a new cancellation token that cannot be cancelled.
44+
*
45+
* <p>This is only useful for cases when a cancellation token is required
46+
* but is not supported or irrelevant, for example, in tests.
47+
*/
48+
static CancellationToken disabled() {
49+
return DisabledCancellationToken.INSTANCE;
50+
}
51+
52+
/**
53+
* {@return whether cancellation has been requested}
54+
*
55+
* <p>Once this method returns {@code true}, it will never return
56+
* {@code false} in a subsequent call.
57+
*/
58+
boolean isCancellationRequested();
59+
60+
/**
61+
* Request cancellation.
62+
*
63+
* <p>This will call subsequent calls to {@link #isCancellationRequested()}
64+
* to return {@code true}.
65+
*/
66+
void cancel();
67+
68+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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.platform.engine;
12+
13+
/**
14+
* @since 6.0
15+
*/
16+
final class DisabledCancellationToken implements CancellationToken {
17+
18+
static final DisabledCancellationToken INSTANCE = new DisabledCancellationToken();
19+
20+
private DisabledCancellationToken() {
21+
}
22+
23+
@Override
24+
public boolean isCancellationRequested() {
25+
return false;
26+
}
27+
28+
@Override
29+
public void cancel() {
30+
}
31+
}

0 commit comments

Comments
 (0)