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.
* Passing the `--fail-fast` option to the `execute` subcommand of the `ConsoleLauncher`
now causes test execution to be cancelled after the first failed test.
* Provide cancellation support for implementations of `{HierarchicalTestEngine}` such as
JUnit Jupiter, Spock, and Cucumber.
* Provide cancellation support for Suite engine
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ class ExecuteTestsCommand extends BaseCommand<TestExecutionSummary> implements C
@Override
protected TestExecutionSummary execute(PrintWriter out) {
return consoleTestExecutorFactory.create(toTestDiscoveryOptions(), toTestConsoleOutputOptions()) //
.execute(out, getReportsDir());
.execute(out, getReportsDir(), isFailFast());
}

Optional<Path> getReportsDir() {
return getReportingOptions().flatMap(ReportingOptions::getReportsDir);
}

boolean isFailFast() {
return getReportingOptions().map(options -> options.failFast).orElse(false);
}

private Optional<ReportingOptions> getReportingOptions() {
return Optional.ofNullable(reportingOptions);
}
Expand Down Expand Up @@ -96,7 +100,13 @@ public int getExitCode() {
static class ReportingOptions {

@Option(names = "--fail-if-no-tests", description = "Fail and return exit status code 2 if no tests are found.")
private boolean failIfNoTests; // no single-dash equivalent: was introduced in 5.3-M1
private boolean failIfNoTests;

/**
* @since 6.0
*/
@Option(names = "--fail-fast", description = "Stops test execution after the first failed test.")
private boolean failFast;

@Nullable
@Option(names = "--reports-dir", paramLabel = "DIR", description = "Enable report output into a specified local directory (will be created if it does not exist).")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

package org.junit.platform.console.tasks;

import static java.util.Objects.requireNonNullElseGet;
import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.platform.console.tasks.DiscoveryRequestCreator.toDiscoveryRequestBuilder;
import static org.junit.platform.launcher.LauncherConstants.OUTPUT_DIR_PROPERTY_NAME;
Expand All @@ -25,17 +26,18 @@
import java.util.function.Supplier;

import org.apiguardian.api.API;
import org.jspecify.annotations.Nullable;
import org.junit.platform.commons.JUnitException;
import org.junit.platform.commons.util.ClassLoaderUtils;
import org.junit.platform.console.options.Details;
import org.junit.platform.console.options.TestConsoleOutputOptions;
import org.junit.platform.console.options.TestDiscoveryOptions;
import org.junit.platform.console.options.Theme;
import org.junit.platform.engine.CancellationToken;
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestPlan;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;
Expand Down Expand Up @@ -83,9 +85,9 @@ public void discover(PrintWriter out) {
});
}

public TestExecutionSummary execute(PrintWriter out, Optional<Path> reportsDir) {
public TestExecutionSummary execute(PrintWriter out, Optional<Path> reportsDir, boolean failFast) {
return createCustomContextClassLoaderExecutor() //
.invoke(() -> executeTests(out, reportsDir));
.invoke(() -> executeTests(out, reportsDir, failFast));
}

private CustomContextClassLoaderExecutor createCustomContextClassLoaderExecutor() {
Expand Down Expand Up @@ -115,16 +117,17 @@ private static void printFoundTestsSummary(PrintWriter out, TestPlan testPlan) {
out.flush();
}

private TestExecutionSummary executeTests(PrintWriter out, Optional<Path> reportsDir) {
private TestExecutionSummary executeTests(PrintWriter out, Optional<Path> reportsDir, boolean failFast) {
Launcher launcher = launcherSupplier.get();
SummaryGeneratingListener summaryListener = registerListeners(out, reportsDir, launcher);
CancellationToken cancellationToken = failFast ? CancellationToken.create() : null;
SummaryGeneratingListener summaryListener = registerListeners(out, reportsDir, launcher, cancellationToken);

PrintStream originalOut = System.out;
PrintStream originalErr = System.err;
try (StandardStreamsHandler standardStreamsHandler = new StandardStreamsHandler()) {
standardStreamsHandler.redirectStandardStreams(outputOptions.getStdoutPath(),
outputOptions.getStderrPath());
launchTests(launcher, reportsDir);
launchTests(launcher, reportsDir, cancellationToken);
}
finally {
System.setOut(originalOut);
Expand All @@ -136,14 +139,24 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional<Path> report
printSummary(summary, out);
}

if (cancellationToken != null && cancellationToken.isCancellationRequested()) {
out.println("Test execution was cancelled due to --fail-fast mode.");
out.println();
}

return summary;
}

private void launchTests(Launcher launcher, Optional<Path> reportsDir) {
LauncherDiscoveryRequestBuilder discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions);
private void launchTests(Launcher launcher, Optional<Path> reportsDir,
@Nullable CancellationToken cancellationToken) {

var discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions);
reportsDir.ifPresent(dir -> discoveryRequestBuilder.configurationParameter(OUTPUT_DIR_PROPERTY_NAME,
dir.toAbsolutePath().toString()));
launcher.execute(discoveryRequestBuilder.forExecution().build());
var executionRequest = discoveryRequestBuilder.forExecution() //
.cancellationToken(requireNonNullElseGet(cancellationToken, CancellationToken::disabled)) //
.build();
launcher.execute(executionRequest);
}

private Optional<ClassLoader> createCustomClassLoader() {
Expand All @@ -166,14 +179,17 @@ private URL toURL(Path path) {
}
}

private SummaryGeneratingListener registerListeners(PrintWriter out, Optional<Path> reportsDir, Launcher launcher) {
private SummaryGeneratingListener registerListeners(PrintWriter out, Optional<Path> reportsDir, Launcher launcher,
@Nullable CancellationToken cancellationToken) {

// always register summary generating listener
SummaryGeneratingListener summaryListener = new SummaryGeneratingListener();
launcher.registerTestExecutionListeners(summaryListener);
// optionally, register test plan execution details printing listener
createDetailsPrintingListener(out).ifPresent(launcher::registerTestExecutionListeners);
// optionally, register XML reports writing listener
createXmlWritingListener(out, reportsDir).ifPresent(launcher::registerTestExecutionListeners);
createFailFastListener(cancellationToken).ifPresent(launcher::registerTestExecutionListeners);
return summaryListener;
}

Expand Down Expand Up @@ -209,6 +225,10 @@ private Optional<TestExecutionListener> createXmlWritingListener(PrintWriter out
return reportsDir.map(it -> new LegacyXmlReportGeneratingListener(it, out));
}

private Optional<TestExecutionListener> createFailFastListener(@Nullable CancellationToken cancellationToken) {
return Optional.ofNullable(cancellationToken).map(FailFastListener::new);
}

private void printSummary(TestExecutionSummary summary, PrintWriter out) {
// Otherwise the failures have already been printed in detail
if (EnumSet.of(Details.NONE, Details.SUMMARY, Details.TREE).contains(outputOptions.getDetails())) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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.platform.console.tasks;

import static org.junit.platform.engine.TestExecutionResult.Status.FAILED;

import org.junit.platform.engine.CancellationToken;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;

/**
* @since 6.0
*/
class FailFastListener implements TestExecutionListener {

private final CancellationToken cancellationToken;

FailFastListener(CancellationToken cancellationToken) {
this.cancellationToken = cancellationToken;
}

@Override
public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
if (testExecutionResult.getStatus() == FAILED) {
cancellationToken.cancel();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.junit.jupiter.params.provider.FieldSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.platform.console.options.StdStreamTestCase;
import org.junit.platform.console.subpackage.FailingTestCase;

/**
* @since 1.0
Expand Down Expand Up @@ -123,4 +124,16 @@ void executeWithRedirectedStdStreamsToSameFile(@TempDir Path tempDir) throws IOE
Files.size(outputFile), "Invalid file size.");
}

@Test
void stopsAfterFirstFailingTest() {
var result = new ConsoleLauncherWrapper().execute(1, "execute", "-e", "junit-jupiter", "--select-class",
FailingTestCase.class.getName(), "--fail-fast", "--disable-ansi-colors");

assertThat(result.getTestsStartedCount()).isEqualTo(1);
assertThat(result.getTestsFailedCount()).isEqualTo(1);
assertThat(result.getTestsSkippedCount()).isEqualTo(1);

assertThat(result.out).endsWith("%nTest execution was cancelled due to --fail-fast mode.%n%n".formatted());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public ConsoleLauncherWrapperResult execute(Optional<Integer> expectedCode, Stri
if (expectedCode.isPresent()) {
int expectedValue = expectedCode.get();
assertEquals(expectedValue, code, "ConsoleLauncher execute code mismatch!");
if (expectedValue != 0) {
if (expectedValue != 0 && expectedValue != 1) {
assertThat(errText).isNotBlank();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Expand All @@ -36,7 +39,7 @@ class ExecuteTestsCommandTests {

@BeforeEach
void setUp() {
when(consoleTestExecutor.execute(any(), any())).thenReturn(summary);
when(consoleTestExecutor.execute(any(), any(), anyBoolean())).thenReturn(summary);
}

@Test
Expand Down Expand Up @@ -106,6 +109,16 @@ void parseValidXmlReportsDirs() {
// @formatter:on
}

@Test
void parseValidFailFast() {
// @formatter:off
assertAll(
() -> assertFalse(parseArgs().isFailFast()),
() -> assertTrue(parseArgs("--fail-fast").isFailFast())
);
// @formatter:on
}

private ExecuteTestsCommand parseArgs(String... args) {
command.parseArgs(args);
return command;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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.platform.console.subpackage;

import static org.junit.jupiter.api.Assertions.fail;

import org.junit.jupiter.api.Test;

public class FailingTestCase {

@Test
void first() {
fail();
}

@Test
void second() {
fail();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ void printsSummary() {
dummyTestEngine.addTest("failingTest", FAILING_BLOCK);

var task = new ConsoleTestExecutor(discoveryOptions, outputOptions, () -> createLauncher(dummyTestEngine));
task.execute(new PrintWriter(stringWriter), Optional.empty());
task.execute(new PrintWriter(stringWriter), Optional.empty(), false);

assertThat(stringWriter.toString()).contains("Test run finished after", "2 tests found", "0 tests skipped",
"2 tests started", "0 tests aborted", "1 tests successful", "1 tests failed");
Expand All @@ -66,7 +66,7 @@ void printsDetailsIfTheyAreNotHidden() {
dummyTestEngine.addTest("failingTest", FAILING_BLOCK);

var task = new ConsoleTestExecutor(discoveryOptions, outputOptions, () -> createLauncher(dummyTestEngine));
task.execute(new PrintWriter(stringWriter), Optional.empty());
task.execute(new PrintWriter(stringWriter), Optional.empty(), false);

assertThat(stringWriter.toString()).contains("Test execution started.");
}
Expand All @@ -78,7 +78,7 @@ void printsNoDetailsIfTheyAreHidden() {
dummyTestEngine.addTest("failingTest", FAILING_BLOCK);

var task = new ConsoleTestExecutor(discoveryOptions, outputOptions, () -> createLauncher(dummyTestEngine));
task.execute(new PrintWriter(stringWriter), Optional.empty());
task.execute(new PrintWriter(stringWriter), Optional.empty(), false);

assertThat(stringWriter.toString()).doesNotContain("Test execution started.");
}
Expand All @@ -91,7 +91,7 @@ void printsFailuresEvenIfDetailsAreHidden() {
dummyTestEngine.addContainer("failingContainer", FAILING_BLOCK);

var task = new ConsoleTestExecutor(discoveryOptions, outputOptions, () -> createLauncher(dummyTestEngine));
task.execute(new PrintWriter(stringWriter), Optional.empty());
task.execute(new PrintWriter(stringWriter), Optional.empty(), false);

assertThat(stringWriter.toString()).contains("Failures (2)", "failingTest", "failingContainer");
}
Expand All @@ -105,7 +105,7 @@ void usesCustomClassLoaderIfAdditionalClassPathEntriesArePresent() {
() -> assertSame(oldClassLoader, getDefaultClassLoader(), "should fail"));

var task = new ConsoleTestExecutor(discoveryOptions, outputOptions, () -> createLauncher(dummyTestEngine));
task.execute(new PrintWriter(stringWriter), Optional.empty());
task.execute(new PrintWriter(stringWriter), Optional.empty(), false);

assertThat(stringWriter.toString()).contains("failingTest", "should fail", "1 tests failed");
}
Expand All @@ -119,7 +119,7 @@ void usesSameClassLoaderIfNoAdditionalClassPathEntriesArePresent() {
() -> assertNotSame(oldClassLoader, getDefaultClassLoader(), "should fail"));

var task = new ConsoleTestExecutor(discoveryOptions, outputOptions, () -> createLauncher(dummyTestEngine));
task.execute(new PrintWriter(stringWriter), Optional.empty());
task.execute(new PrintWriter(stringWriter), Optional.empty(), false);

assertThat(stringWriter.toString()).contains("failingTest", "should fail", "1 tests failed");
}
Expand Down