Skip to content

Commit 6c33cd4

Browse files
authored
Introduce --fail-fast mode for execute command of ConsoleLauncher (#4732)
Passing the option to the `execute` subcommand of the `ConsoleLauncher` now causes test execution to be cancelled after the first failed test. Issue: #4725
1 parent fe1beff commit 6c33cd4

File tree

9 files changed

+143
-20
lines changed

9 files changed

+143
-20
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+
* Passing the `--fail-fast` option to the `execute` subcommand of the `ConsoleLauncher`
45+
now causes test execution to be cancelled after the first failed test.
4446
* Provide cancellation support for implementations of `{HierarchicalTestEngine}` such as
4547
JUnit Jupiter, Spock, and Cucumber.
4648
* Provide cancellation support for Suite engine

junit-platform-console/src/main/java/org/junit/platform/console/options/ExecuteTestsCommand.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,17 @@ class ExecuteTestsCommand extends BaseCommand<TestExecutionSummary> implements C
6060
@Override
6161
protected TestExecutionSummary execute(PrintWriter out) {
6262
return consoleTestExecutorFactory.create(toTestDiscoveryOptions(), toTestConsoleOutputOptions()) //
63-
.execute(out, getReportsDir());
63+
.execute(out, getReportsDir(), isFailFast());
6464
}
6565

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

70+
boolean isFailFast() {
71+
return getReportingOptions().map(options -> options.failFast).orElse(false);
72+
}
73+
7074
private Optional<ReportingOptions> getReportingOptions() {
7175
return Optional.ofNullable(reportingOptions);
7276
}
@@ -96,7 +100,13 @@ public int getExitCode() {
96100
static class ReportingOptions {
97101

98102
@Option(names = "--fail-if-no-tests", description = "Fail and return exit status code 2 if no tests are found.")
99-
private boolean failIfNoTests; // no single-dash equivalent: was introduced in 5.3-M1
103+
private boolean failIfNoTests;
104+
105+
/**
106+
* @since 6.0
107+
*/
108+
@Option(names = "--fail-fast", description = "Stops test execution after the first failed test.")
109+
private boolean failFast;
100110

101111
@Nullable
102112
@Option(names = "--reports-dir", paramLabel = "DIR", description = "Enable report output into a specified local directory (will be created if it does not exist).")

junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
package org.junit.platform.console.tasks;
1212

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

2728
import org.apiguardian.api.API;
29+
import org.jspecify.annotations.Nullable;
2830
import org.junit.platform.commons.JUnitException;
2931
import org.junit.platform.commons.util.ClassLoaderUtils;
3032
import org.junit.platform.console.options.Details;
3133
import org.junit.platform.console.options.TestConsoleOutputOptions;
3234
import org.junit.platform.console.options.TestDiscoveryOptions;
3335
import org.junit.platform.console.options.Theme;
36+
import org.junit.platform.engine.CancellationToken;
3437
import org.junit.platform.launcher.Launcher;
3538
import org.junit.platform.launcher.LauncherDiscoveryRequest;
3639
import org.junit.platform.launcher.TestExecutionListener;
3740
import org.junit.platform.launcher.TestPlan;
38-
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
3941
import org.junit.platform.launcher.core.LauncherFactory;
4042
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
4143
import org.junit.platform.launcher.listeners.TestExecutionSummary;
@@ -83,9 +85,9 @@ public void discover(PrintWriter out) {
8385
});
8486
}
8587

86-
public TestExecutionSummary execute(PrintWriter out, Optional<Path> reportsDir) {
88+
public TestExecutionSummary execute(PrintWriter out, Optional<Path> reportsDir, boolean failFast) {
8789
return createCustomContextClassLoaderExecutor() //
88-
.invoke(() -> executeTests(out, reportsDir));
90+
.invoke(() -> executeTests(out, reportsDir, failFast));
8991
}
9092

9193
private CustomContextClassLoaderExecutor createCustomContextClassLoaderExecutor() {
@@ -115,16 +117,17 @@ private static void printFoundTestsSummary(PrintWriter out, TestPlan testPlan) {
115117
out.flush();
116118
}
117119

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

122125
PrintStream originalOut = System.out;
123126
PrintStream originalErr = System.err;
124127
try (StandardStreamsHandler standardStreamsHandler = new StandardStreamsHandler()) {
125128
standardStreamsHandler.redirectStandardStreams(outputOptions.getStdoutPath(),
126129
outputOptions.getStderrPath());
127-
launchTests(launcher, reportsDir);
130+
launchTests(launcher, reportsDir, cancellationToken);
128131
}
129132
finally {
130133
System.setOut(originalOut);
@@ -136,14 +139,24 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional<Path> report
136139
printSummary(summary, out);
137140
}
138141

142+
if (cancellationToken != null && cancellationToken.isCancellationRequested()) {
143+
out.println("Test execution was cancelled due to --fail-fast mode.");
144+
out.println();
145+
}
146+
139147
return summary;
140148
}
141149

142-
private void launchTests(Launcher launcher, Optional<Path> reportsDir) {
143-
LauncherDiscoveryRequestBuilder discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions);
150+
private void launchTests(Launcher launcher, Optional<Path> reportsDir,
151+
@Nullable CancellationToken cancellationToken) {
152+
153+
var discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions);
144154
reportsDir.ifPresent(dir -> discoveryRequestBuilder.configurationParameter(OUTPUT_DIR_PROPERTY_NAME,
145155
dir.toAbsolutePath().toString()));
146-
launcher.execute(discoveryRequestBuilder.forExecution().build());
156+
var executionRequest = discoveryRequestBuilder.forExecution() //
157+
.cancellationToken(requireNonNullElseGet(cancellationToken, CancellationToken::disabled)) //
158+
.build();
159+
launcher.execute(executionRequest);
147160
}
148161

149162
private Optional<ClassLoader> createCustomClassLoader() {
@@ -166,14 +179,17 @@ private URL toURL(Path path) {
166179
}
167180
}
168181

169-
private SummaryGeneratingListener registerListeners(PrintWriter out, Optional<Path> reportsDir, Launcher launcher) {
182+
private SummaryGeneratingListener registerListeners(PrintWriter out, Optional<Path> reportsDir, Launcher launcher,
183+
@Nullable CancellationToken cancellationToken) {
184+
170185
// always register summary generating listener
171186
SummaryGeneratingListener summaryListener = new SummaryGeneratingListener();
172187
launcher.registerTestExecutionListeners(summaryListener);
173188
// optionally, register test plan execution details printing listener
174189
createDetailsPrintingListener(out).ifPresent(launcher::registerTestExecutionListeners);
175190
// optionally, register XML reports writing listener
176191
createXmlWritingListener(out, reportsDir).ifPresent(launcher::registerTestExecutionListeners);
192+
createFailFastListener(cancellationToken).ifPresent(launcher::registerTestExecutionListeners);
177193
return summaryListener;
178194
}
179195

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

228+
private Optional<TestExecutionListener> createFailFastListener(@Nullable CancellationToken cancellationToken) {
229+
return Optional.ofNullable(cancellationToken).map(FailFastListener::new);
230+
}
231+
212232
private void printSummary(TestExecutionSummary summary, PrintWriter out) {
213233
// Otherwise the failures have already been printed in detail
214234
if (EnumSet.of(Details.NONE, Details.SUMMARY, Details.TREE).contains(outputOptions.getDetails())) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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.console.tasks;
12+
13+
import static org.junit.platform.engine.TestExecutionResult.Status.FAILED;
14+
15+
import org.junit.platform.engine.CancellationToken;
16+
import org.junit.platform.engine.TestExecutionResult;
17+
import org.junit.platform.launcher.TestExecutionListener;
18+
import org.junit.platform.launcher.TestIdentifier;
19+
20+
/**
21+
* @since 6.0
22+
*/
23+
class FailFastListener implements TestExecutionListener {
24+
25+
private final CancellationToken cancellationToken;
26+
27+
FailFastListener(CancellationToken cancellationToken) {
28+
this.cancellationToken = cancellationToken;
29+
}
30+
31+
@Override
32+
public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
33+
if (testExecutionResult.getStatus() == FAILED) {
34+
cancellationToken.cancel();
35+
}
36+
}
37+
}

platform-tests/src/test/java/org/junit/platform/console/ConsoleLauncherIntegrationTests.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.junit.jupiter.params.provider.FieldSource;
3030
import org.junit.jupiter.params.provider.ValueSource;
3131
import org.junit.platform.console.options.StdStreamTestCase;
32+
import org.junit.platform.console.subpackage.FailingTestCase;
3233

3334
/**
3435
* @since 1.0
@@ -123,4 +124,16 @@ void executeWithRedirectedStdStreamsToSameFile(@TempDir Path tempDir) throws IOE
123124
Files.size(outputFile), "Invalid file size.");
124125
}
125126

127+
@Test
128+
void stopsAfterFirstFailingTest() {
129+
var result = new ConsoleLauncherWrapper().execute(1, "execute", "-e", "junit-jupiter", "--select-class",
130+
FailingTestCase.class.getName(), "--fail-fast", "--disable-ansi-colors");
131+
132+
assertThat(result.getTestsStartedCount()).isEqualTo(1);
133+
assertThat(result.getTestsFailedCount()).isEqualTo(1);
134+
assertThat(result.getTestsSkippedCount()).isEqualTo(1);
135+
136+
assertThat(result.out).endsWith("%nTest execution was cancelled due to --fail-fast mode.%n%n".formatted());
137+
}
138+
126139
}

platform-tests/src/test/java/org/junit/platform/console/ConsoleLauncherWrapper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public ConsoleLauncherWrapperResult execute(Optional<Integer> expectedCode, Stri
5555
if (expectedCode.isPresent()) {
5656
int expectedValue = expectedCode.get();
5757
assertEquals(expectedValue, code, "ConsoleLauncher execute code mismatch!");
58-
if (expectedValue != 0) {
58+
if (expectedValue != 0 && expectedValue != 1) {
5959
assertThat(errText).isNotBlank();
6060
}
6161
}

platform-tests/src/test/java/org/junit/platform/console/options/ExecuteTestsCommandTests.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
import static org.assertj.core.api.Assertions.assertThat;
1414
import static org.junit.jupiter.api.Assertions.assertAll;
1515
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.junit.jupiter.api.Assertions.assertFalse;
17+
import static org.junit.jupiter.api.Assertions.assertTrue;
1618
import static org.mockito.ArgumentMatchers.any;
19+
import static org.mockito.ArgumentMatchers.anyBoolean;
1720
import static org.mockito.Mockito.mock;
1821
import static org.mockito.Mockito.when;
1922

@@ -36,7 +39,7 @@ class ExecuteTestsCommandTests {
3639

3740
@BeforeEach
3841
void setUp() {
39-
when(consoleTestExecutor.execute(any(), any())).thenReturn(summary);
42+
when(consoleTestExecutor.execute(any(), any(), anyBoolean())).thenReturn(summary);
4043
}
4144

4245
@Test
@@ -106,6 +109,16 @@ void parseValidXmlReportsDirs() {
106109
// @formatter:on
107110
}
108111

112+
@Test
113+
void parseValidFailFast() {
114+
// @formatter:off
115+
assertAll(
116+
() -> assertFalse(parseArgs().isFailFast()),
117+
() -> assertTrue(parseArgs("--fail-fast").isFailFast())
118+
);
119+
// @formatter:on
120+
}
121+
109122
private ExecuteTestsCommand parseArgs(String... args) {
110123
command.parseArgs(args);
111124
return command;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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.console.subpackage;
12+
13+
import static org.junit.jupiter.api.Assertions.fail;
14+
15+
import org.junit.jupiter.api.Test;
16+
17+
public class FailingTestCase {
18+
19+
@Test
20+
void first() {
21+
fail();
22+
}
23+
24+
@Test
25+
void second() {
26+
fail();
27+
}
28+
}

platform-tests/src/test/java/org/junit/platform/console/tasks/ConsoleTestExecutorTests.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ void printsSummary() {
5353
dummyTestEngine.addTest("failingTest", FAILING_BLOCK);
5454

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

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

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

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

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

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

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

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

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

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

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

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

0 commit comments

Comments
 (0)