Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<test-engines>> checking and responding to the
[NOTE]
.Test Engine Support for Cancellation
====
Cancelling tests relies on <<test-engines>> checking and responding to the
`{CancellationToken}` appropriately (see
<<test-engines-requirements-cancellation, Test Engine Requirements>> 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
====
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -41,6 +42,9 @@ public HierarchicalTestEngine() {
* its {@linkplain ExecutionRequest#getEngineExecutionListener() execution
* listener} of test execution events.
*
* <p>Supports cancellation via the {@link CancellationToken} passed in the
* supplied {@code request}.
*
* @see Node
* @see #createExecutorService
* @see #createExecutionContext
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,14 +49,23 @@ class HierarchicalTestExecutor<C extends EngineExecutionContext> {
}

Future<@Nullable Void> execute() {
return this.executorService.submit(createRootTestTask());
}

private NodeTestTask<C> 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<C> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class NodeTestTask<C extends EngineExecutionContext> 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<C> node;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) //
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DynamicNode> testFactory() {
return Stream.of( //
dynamicTest("first", () -> requiredCancellationToken().cancel()), //
dynamicContainer("container", Stream.of( //
dynamicTest("second", () -> fail("should not be called")) //
)) //
);
}
}

}
Loading