diff --git a/build.gradle.kts b/build.gradle.kts index d2bf526..bd8f804 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,12 @@ repositories { snapshotsOnly() } } + maven(url = "https://central.sonatype.com/repository/maven-snapshots") { + mavenContent { + includeGroupByRegex("org\\.junit.*") + snapshotsOnly() + } + } } val moduleSourceSet = sourceSets.create("module") { @@ -109,7 +115,7 @@ dependencies { } } - testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation(libs.versions.latestJUnit.map { "org.junit.jupiter:junit-jupiter:$it" }) testImplementation("org.junit.platform:junit-platform-testkit") testImplementation(libs.mockito.junit.jupiter) testImplementation(libs.assertj.core) @@ -123,21 +129,22 @@ dependencies { testRuntimeOnly("org.apache.logging.log4j:log4j-jul") testFixturesImplementation(libs.junit4) + testFixturesImplementation(libs.versions.latestJUnit.map { "org.junit.platform:junit-platform-engine:$it" }) testFixturesRuntimeOnly("org.junit.platform:junit-platform-console") } tasks { - listOf(compileJava, compileTestFixturesJava).forEach { task -> - task.configure { - options.release.set(8) - if (javaToolchainVersion >= JavaLanguageVersion.of(20)) { - // `--release=8` is deprecated on JDK 20 and later - options.compilerArgs.add("-Xlint:-options") - } + compileJava { + options.release.set(8) + if (javaToolchainVersion >= JavaLanguageVersion.of(20)) { + // `--release=8` is deprecated on JDK 20 and later + options.compilerArgs.add("-Xlint:-options") } } - compileTestJava { - options.release.set(17) + listOf(compileTestJava, compileTestFixturesJava).forEach { task -> + task.configure { + options.release.set(17) + } } named(moduleSourceSet.compileJavaTaskName) { options.release.set(9) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f37085..823fde2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] latestTestNG = "7.11.0" # Keep in sync with TestContext.java and README.adoc snapshotTestNG = "7.12.0-SNAPSHOT" +latestJUnit = "6.0.0-SNAPSHOT" [libraries] assertj-core = { module = "org.assertj:assertj-core", version = "3.27.3" } diff --git a/src/main/java/org/junit/support/testng/engine/DefaultListener.java b/src/main/java/org/junit/support/testng/engine/DefaultListener.java index f4bf5f8..3e181a3 100644 --- a/src/main/java/org/junit/support/testng/engine/DefaultListener.java +++ b/src/main/java/org/junit/support/testng/engine/DefaultListener.java @@ -15,6 +15,8 @@ import org.testng.IAlterSuiteListener; import org.testng.IClassListener; import org.testng.IConfigurationListener; +import org.testng.IInvokedMethod; +import org.testng.IInvokedMethodListener; import org.testng.ITestClass; import org.testng.ITestContext; import org.testng.ITestListener; @@ -22,7 +24,8 @@ import org.testng.ITestResult; import org.testng.xml.XmlSuite; -abstract class DefaultListener implements IClassListener, ITestListener, IConfigurationListener, IAlterSuiteListener { +abstract class DefaultListener + implements IClassListener, ITestListener, IConfigurationListener, IAlterSuiteListener, IInvokedMethodListener { @Override public void alter(List suites) { @@ -99,4 +102,20 @@ public void beforeConfiguration(ITestResult tr) { @Override public void beforeConfiguration(ITestResult tr, ITestNGMethod tm) { } + + @Override + public void beforeInvocation(IInvokedMethod method, ITestResult testResult) { + } + + @Override + public void afterInvocation(IInvokedMethod method, ITestResult testResult) { + } + + @Override + public void beforeInvocation(IInvokedMethod method, ITestResult testResult, ITestContext context) { + } + + @Override + public void afterInvocation(IInvokedMethod method, ITestResult testResult, ITestContext context) { + } } diff --git a/src/main/java/org/junit/support/testng/engine/ExecutionListener.java b/src/main/java/org/junit/support/testng/engine/ExecutionListener.java index 1c77bd0..2f15c14 100644 --- a/src/main/java/org/junit/support/testng/engine/ExecutionListener.java +++ b/src/main/java/org/junit/support/testng/engine/ExecutionListener.java @@ -13,6 +13,7 @@ import static java.util.Collections.emptyMap; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toMap; +import static org.junit.platform.engine.TestExecutionResult.Status.SUCCESSFUL; import static org.junit.platform.engine.TestExecutionResult.aborted; import static org.junit.platform.engine.TestExecutionResult.failed; import static org.junit.platform.engine.TestExecutionResult.successful; @@ -27,14 +28,17 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; import java.util.stream.Stream; import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.TestExecutionResult; import org.junit.platform.engine.reporting.ReportEntry; +import org.testng.IInvokedMethod; import org.testng.ITestClass; import org.testng.ITestNGMethod; import org.testng.ITestResult; +import org.testng.SkipException; import org.testng.annotations.CustomAttribute; class ExecutionListener extends DefaultListener { @@ -46,13 +50,30 @@ class ExecutionListener extends DefaultListener { private final Map> classLevelFailureResults = new ConcurrentHashMap<>(); private final EngineExecutionListener delegate; + private final BooleanSupplier cancellationToken; private final TestNGEngineDescriptor engineDescriptor; - ExecutionListener(EngineExecutionListener delegate, TestNGEngineDescriptor engineDescriptor) { + private volatile SkipException skipException; + + ExecutionListener(EngineExecutionListener delegate, BooleanSupplier cancellationToken, + TestNGEngineDescriptor engineDescriptor) { this.delegate = delegate; + this.cancellationToken = cancellationToken; this.engineDescriptor = engineDescriptor; } + @Override + public void beforeInvocation(IInvokedMethod method, ITestResult testResult) { + if (cancellationToken.getAsBoolean()) { + SkipException exception = skipException; + if (exception == null) { + exception = new SkipException("Execution cancelled"); + skipException = exception; + } + throw exception; + } + } + @Override public void onBeforeClass(ITestClass testClass) { ClassDescriptor classDescriptor = requireNonNull(engineDescriptor.findClassDescriptor(testClass.getRealClass()), @@ -218,7 +239,11 @@ private TestDescriptorFactory getTestDescriptorFactory() { } public TestExecutionResult toEngineResult() { - return toTestExecutionResult(engineLevelFailureResults); + TestExecutionResult testExecutionResult = toTestExecutionResult(engineLevelFailureResults); + if (testExecutionResult.getStatus() == SUCCESSFUL && skipException != null) { + return aborted(skipException); + } + return testExecutionResult; } private TestExecutionResult toTestExecutionResult(Set results) { diff --git a/src/main/java/org/junit/support/testng/engine/TestNGTestEngine.java b/src/main/java/org/junit/support/testng/engine/TestNGTestEngine.java index 16daae5..0282342 100644 --- a/src/main/java/org/junit/support/testng/engine/TestNGTestEngine.java +++ b/src/main/java/org/junit/support/testng/engine/TestNGTestEngine.java @@ -15,7 +15,10 @@ import static org.testng.internal.RuntimeBehavior.TESTNG_MODE_DRYRUN; import java.util.List; +import java.util.Optional; +import java.util.function.BooleanSupplier; +import org.junit.platform.commons.support.ReflectionSupport; import org.junit.platform.engine.ConfigurationParameters; import org.junit.platform.engine.EngineDiscoveryRequest; import org.junit.platform.engine.EngineExecutionListener; @@ -26,6 +29,7 @@ import org.junit.platform.engine.support.discovery.EngineDiscoveryRequestResolver; import org.testng.CommandLineArgs; import org.testng.ITestNGListener; +import org.testng.SkipException; import org.testng.TestNG; import org.testng.annotations.DataProvider; import org.testng.xml.XmlSuite.ParallelMode; @@ -159,14 +163,22 @@ public TestDescriptor discover(EngineDiscoveryRequest request, UniqueId uniqueId @Override public void execute(ExecutionRequest request) { EngineExecutionListener listener = request.getEngineExecutionListener(); + BooleanSupplier cancellationToken = getCancellationToken(request); TestNGEngineDescriptor engineDescriptor = (TestNGEngineDescriptor) request.getRootTestDescriptor(); listener.executionStarted(engineDescriptor); engineDescriptor.prepareExecution(); - ExecutionListener executionListener = new ExecutionListener(listener, engineDescriptor); + ExecutionListener executionListener = new ExecutionListener(listener, cancellationToken, engineDescriptor); List methodNames = engineDescriptor.getQualifiedMethodNames(); if (!methodNames.isEmpty()) { - configureAndRun(request.getConfigurationParameters(), executionListener, testMethods(methodNames), - Phase.EXECUTION); + try { + configureAndRun(request.getConfigurationParameters(), executionListener, testMethods(methodNames), + Phase.EXECUTION); + } + catch (SkipException e) { + if (!cancellationToken.getAsBoolean()) { + throw e; + } + } } listener.executionFinished(engineDescriptor, executionListener.toEngineResult()); } @@ -207,6 +219,18 @@ private static void withTemporarySystemProperty(String key, String value, Runnab } } + private static BooleanSupplier getCancellationToken(ExecutionRequest request) { + return ReflectionSupport.findMethod(ExecutionRequest.class, "getCancellationToken") // + .map(method -> ReflectionSupport.invokeMethod(method, request)) // + .flatMap(TestNGTestEngine::toBooleanSupplier) // + .orElse(() -> false); + } + + private static Optional toBooleanSupplier(Object cancellationToken) { + return ReflectionSupport.findMethod(cancellationToken.getClass(), "isCancellationRequested") // + .map(method -> () -> (boolean) ReflectionSupport.invokeMethod(method, cancellationToken)); + } + interface Configurer { static Configurer testClasses(Class[] testClasses) { diff --git a/src/test/java/org/junit/support/testng/engine/DiscoveryIntegrationTests.java b/src/test/java/org/junit/support/testng/engine/DiscoveryIntegrationTests.java index c107159..aafc7cd 100644 --- a/src/test/java/org/junit/support/testng/engine/DiscoveryIntegrationTests.java +++ b/src/test/java/org/junit/support/testng/engine/DiscoveryIntegrationTests.java @@ -23,6 +23,7 @@ import static org.junit.platform.engine.discovery.DiscoverySelectors.selectUniqueId; import static org.junit.platform.launcher.TagFilter.includeTags; import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request; +import static org.junit.platform.launcher.core.LauncherExecutionRequestBuilder.request; import java.util.Map; import java.util.regex.Pattern; @@ -209,18 +210,26 @@ void discoversAllClassesViaPackageSelector() { @Test void supportsPostDiscoveryFilters() { - var request = request().selectors(selectClass(SimpleTestCase.class)).filters(includeTags("bar")).build(); var launcher = LauncherFactory.create( LauncherConfig.builder().enableTestEngineAutoRegistration(false).addTestEngines(testEngine).build()); - var listener = new SummaryGeneratingListener(); - var testPlan = launcher.discover(request); - launcher.execute(testPlan, listener); + var discoveryRequest = request() // + .selectors(selectClass(SimpleTestCase.class)) // + .filters(includeTags("bar")) // + .build(); + var testPlan = launcher.discover(discoveryRequest); var rootIdentifier = getOnlyElement(testPlan.getRoots()); var classIdentifier = getOnlyElement(testPlan.getChildren(rootIdentifier)); var methodIdentifier = getOnlyElement(testPlan.getChildren(classIdentifier)); assertThat(methodIdentifier.getDisplayName()).isEqualTo("successful"); + + var listener = new SummaryGeneratingListener(); + var executionRequest = request(testPlan) // + .listeners(listener) // + .build(); + launcher.execute(executionRequest); + assertThat(listener.getSummary().getTestsStartedCount()).isEqualTo(1); assertThat(listener.getSummary().getTestsSucceededCount()).isEqualTo(1); } diff --git a/src/test/java/org/junit/support/testng/engine/ReportingIntegrationTests.java b/src/test/java/org/junit/support/testng/engine/ReportingIntegrationTests.java index b3e4ee7..3d44e94 100644 --- a/src/test/java/org/junit/support/testng/engine/ReportingIntegrationTests.java +++ b/src/test/java/org/junit/support/testng/engine/ReportingIntegrationTests.java @@ -30,14 +30,17 @@ import static org.junit.platform.testkit.engine.EventConditions.test; import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; +import static org.junit.support.testng.engine.TestContext.testNGVersion; import java.util.Map; +import example.basics.CancellingTestCase; import example.basics.CustomAttributeTestCase; import example.basics.ExpectedExceptionsTestCase; import example.basics.InheritingSubClassTestCase; import example.basics.NestedTestClass; import example.basics.ParallelExecutionTestCase; +import example.basics.PostCancellationTestCase; import example.basics.RetriedTestCase; import example.basics.SimpleTestCase; import example.basics.SuccessPercentageTestCase; @@ -46,13 +49,17 @@ import example.configuration.methods.FailingBeforeClassConfigurationMethodTestCase; import example.dataproviders.DataProviderMethodTestCase; +import org.apache.maven.artifact.versioning.ComparableVersion; +import org.assertj.core.api.Condition; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.junit.platform.engine.CancellationToken; import org.junit.platform.engine.Filter; import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.descriptor.MethodSource; import org.junit.platform.launcher.PostDiscoveryFilter; +import org.junit.platform.testkit.engine.Event; import org.testng.SkipException; import org.testng.internal.thread.ThreadTimeoutException; @@ -330,4 +337,45 @@ void onlyExecutesNestedTestClassesThatMatchClassNameFilter() { results.testEvents().assertStatistics(stats -> stats.started(1).finished(1)); } + @Test + void supportsCancellation() { + CancellingTestCase.cancellationToken = CancellationToken.create(); + try { + var results = testNGEngine() // + .selectors(selectClass(CancellingTestCase.class), selectClass(PostCancellationTestCase.class)) // + .cancellationToken(CancellingTestCase.cancellationToken) // + .execute(); + + results.allEvents().assertEventsMatchExactly( // + event(engine(), started()), // + event(testClass(CancellingTestCase.class), started()), // + event(test("method"), started()), // + event(test("method"), finishedSuccessfully()), // + event(test("method"), started()), // + event(test("method"), + abortedWithReason(instanceOf(SkipException.class), message("Execution cancelled"))), // + event(testClass(CancellingTestCase.class), finishedSuccessfully()), // + event(testClass(PostCancellationTestCase.class), started()), // + event(test("method:test"), started()), // + event(test("method:test"), expectedDownstreamTestReportedResultForPriorCancellation()), // + event(testClass(PostCancellationTestCase.class), finishedSuccessfully()), // + event(engine(), abortedWithReason(instanceOf(SkipException.class), message("Execution cancelled")))); + } + finally { + CancellingTestCase.cancellationToken = null; + } + } + + private static Condition expectedDownstreamTestReportedResultForPriorCancellation() { + var currentVersion = testNGVersion(); + if (currentVersion.compareTo(new ComparableVersion("7.0")) < 0) { + return abortedWithReason( + message(it -> it.contains("depends on not successfully finished methods in group \"cancellation\""))); + } + if (currentVersion.compareTo(new ComparableVersion("7.4")) < 0) { + return finishedSuccessfully(); + } + return abortedWithReason(instanceOf(SkipException.class), message("Execution cancelled")); + } + } diff --git a/src/test/java/org/junit/support/testng/engine/TestNGVersionAppendingDisplayNameGenerator.java b/src/test/java/org/junit/support/testng/engine/TestNGVersionAppendingDisplayNameGenerator.java index 6549026..6001c45 100644 --- a/src/test/java/org/junit/support/testng/engine/TestNGVersionAppendingDisplayNameGenerator.java +++ b/src/test/java/org/junit/support/testng/engine/TestNGVersionAppendingDisplayNameGenerator.java @@ -12,14 +12,16 @@ import java.lang.reflect.Method; import java.text.MessageFormat; +import java.util.List; import org.junit.jupiter.api.DisplayNameGenerator; class TestNGVersionAppendingDisplayNameGenerator extends DisplayNameGenerator.Standard { @Override - public String generateDisplayNameForMethod(Class testClass, Method testMethod) { - var regularDisplayName = super.generateDisplayNameForMethod(testClass, testMethod); + public String generateDisplayNameForMethod(List> enclosingInstanceTypes, Class testClass, + Method testMethod) { + var regularDisplayName = super.generateDisplayNameForMethod(enclosingInstanceTypes, testClass, testMethod); return MessageFormat.format("{0} [{1}]", regularDisplayName, TestContext.testNGVersion()); } diff --git a/src/testFixtures/java/example/basics/CancellingTestCase.java b/src/testFixtures/java/example/basics/CancellingTestCase.java new file mode 100644 index 0000000..c694dff --- /dev/null +++ b/src/testFixtures/java/example/basics/CancellingTestCase.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-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 example.basics; + +import org.junit.platform.engine.CancellationToken; +import org.testng.annotations.Test; + +@Test(groups = "cancellation") +public class CancellingTestCase { + + public static CancellationToken cancellationToken; + + @Test + public void first() { + cancellationToken.cancel(); + } + + @Test + public void second() { + cancellationToken.cancel(); + } +} diff --git a/src/testFixtures/java/example/basics/PostCancellationTestCase.java b/src/testFixtures/java/example/basics/PostCancellationTestCase.java new file mode 100644 index 0000000..c9ce860 --- /dev/null +++ b/src/testFixtures/java/example/basics/PostCancellationTestCase.java @@ -0,0 +1,23 @@ +/* + * Copyright 2021-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 example.basics; + +import static org.junit.Assert.fail; + +import org.testng.annotations.Test; + +public class PostCancellationTestCase { + + @Test(dependsOnGroups = "cancellation") + public void test() { + fail(); + } +}