Skip to content

Commit 17b9180

Browse files
authored
Stop further suite discovery when encountering a cycle (#3116)
It is relatively easy to create a test suite that contains itself by accident. E.g: ```java package org.example.tag; @tag("fast") public class FastTest { @test public void test() { System.out.println("I am fast test"); } } ``` ```java package org.example.tag; @tag("slow") public class SlowTest { @test public void test() { System.out.println("I am slow test"); } } ``` ```java package org.example.tag; @SelectPackages("org.example.tag") @IncludeTags("fast") @suite public class TestSuite { } ``` This configuration will result in a a long stack trace with a message explaining that the suite contained a cycle. ``` Configuration error: The suite configuration may not contain a cycle [[engine:junit-platform-suite]/[suite:org.example.tag.TestSuite]/[engine:junit-platform-suite]/[suite:org.example.tag.TestSuite]] ``` And it is is not immediately apparent how a user should resolve this problem. This example could be resolved by explicitly excluding the suite engine by adding `@ExcludeEngine("junit-platform-suite")`. This prevents further discovery. But when multiple suites are involved this may not be right action. Fortunately the suite itself is aware of the cycle and as stopping further discovery is the only reasonable action. This means that this can be done without any sort of explicit configuration. Fixes #3115.
1 parent 50e097a commit 17b9180

File tree

9 files changed

+142
-42
lines changed

9 files changed

+142
-42
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-5.9.2.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ JUnit repository on GitHub.
2727
avoid parsing unique IDs unnecessarily during test execution.
2828
* Support for limiting the `max-pool-size` for parallel execution via a configuration
2929
parameter
30+
* Quietly stop further suite discovery when encountering a cycle in a test suite
3031

3132

3233
[[release-notes-5.9.2-junit-jupiter]]

documentation/src/docs/asciidoc/user-guide/advanced-topics/junit-platform-suite-engine.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ you need _at least one_ other test engine and its dependencies on the classpath.
2020
`TestEngine` API for declarative test suites
2121

2222
NOTE: Both of the required dependencies are aggregated in the `junit-platform-suite`
23-
artifact which can be declard in _test_ scope instead of declaring explicit dependencies
23+
artifact which can be declared in _test_ scope instead of declaring explicit dependencies
2424
on `junit-platform-suite-api` and `junit-platform-suite-engine`.
2525

2626
[[junit-platform-suite-engine-setup-transitive-dependencies]]

junit-platform-suite-engine/src/main/java/org/junit/platform/suite/engine/ClassSelectorResolver.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import java.util.Optional;
1717
import java.util.function.Predicate;
1818

19+
import org.junit.platform.commons.logging.Logger;
20+
import org.junit.platform.commons.logging.LoggerFactory;
1921
import org.junit.platform.commons.util.ReflectionUtils;
2022
import org.junit.platform.engine.ConfigurationParameters;
2123
import org.junit.platform.engine.TestDescriptor;
@@ -30,6 +32,8 @@
3032
*/
3133
final class ClassSelectorResolver implements SelectorResolver {
3234

35+
private static final Logger log = LoggerFactory.getLogger(ClassSelectorResolver.class);
36+
3337
private static final IsSuiteClass isSuiteClass = new IsSuiteClass();
3438

3539
private final Predicate<String> classNameFilter;
@@ -94,7 +98,31 @@ private static Resolution toResolution(Optional<SuiteTestDescriptor> suite) {
9498

9599
private Optional<SuiteTestDescriptor> newSuiteDescriptor(Class<?> suiteClass, TestDescriptor parent) {
96100
UniqueId id = parent.getUniqueId().append(SuiteTestDescriptor.SEGMENT_TYPE, suiteClass.getName());
101+
if (containsCycle(id)) {
102+
log.config(() -> createConfigContainsCycleMessage(suiteClass, id));
103+
return Optional.empty();
104+
}
105+
97106
return Optional.of(new SuiteTestDescriptor(id, suiteClass, configurationParameters));
98107
}
99108

109+
private static boolean containsCycle(UniqueId id) {
110+
List<Segment> segments = id.getSegments();
111+
List<Segment> engineAndSuiteSegment = segments.subList(segments.size() - 2, segments.size());
112+
List<Segment> ancestorSegments = segments.subList(0, segments.size() - 2);
113+
for (int i = 0; i < ancestorSegments.size() - 1; i++) {
114+
List<Segment> candidate = ancestorSegments.subList(i, i + 2);
115+
if (engineAndSuiteSegment.equals(candidate)) {
116+
return true;
117+
}
118+
}
119+
return false;
120+
}
121+
122+
private static String createConfigContainsCycleMessage(Class<?> suiteClass, UniqueId suiteId) {
123+
return String.format(
124+
"The suite configuration of [%s] resulted in a cycle [%s] and will not be discovered a second time.",
125+
suiteClass.getName(), suiteId);
126+
}
127+
100128
}

junit-platform-suite-engine/src/main/java/org/junit/platform/suite/engine/SuiteTestDescriptor.java

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,9 @@
1010

1111
package org.junit.platform.suite.engine;
1212

13-
import static java.util.function.Function.identity;
14-
import static java.util.stream.Collectors.counting;
15-
import static java.util.stream.Collectors.groupingBy;
1613
import static org.junit.platform.commons.util.AnnotationUtils.findAnnotation;
1714
import static org.junit.platform.suite.commons.SuiteLauncherDiscoveryRequestBuilder.request;
1815

19-
import java.util.function.Supplier;
20-
2116
import org.junit.platform.commons.JUnitException;
2217
import org.junit.platform.commons.util.Preconditions;
2318
import org.junit.platform.commons.util.StringUtils;
@@ -26,7 +21,6 @@
2621
import org.junit.platform.engine.TestDescriptor;
2722
import org.junit.platform.engine.TestExecutionResult;
2823
import org.junit.platform.engine.UniqueId;
29-
import org.junit.platform.engine.UniqueId.Segment;
3024
import org.junit.platform.engine.discovery.DiscoverySelectors;
3125
import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor;
3226
import org.junit.platform.engine.support.descriptor.ClassSource;
@@ -60,28 +54,12 @@ final class SuiteTestDescriptor extends AbstractTestDescriptor {
6054
private SuiteLauncher launcher;
6155

6256
SuiteTestDescriptor(UniqueId id, Class<?> suiteClass, ConfigurationParameters configurationParameters) {
63-
super(requireNoCycles(id), getSuiteDisplayName(suiteClass), ClassSource.from(suiteClass));
57+
super(id, getSuiteDisplayName(suiteClass), ClassSource.from(suiteClass));
6458
this.configurationParameters = configurationParameters;
6559
this.failIfNoTests = getFailIfNoTests(suiteClass);
6660
this.suiteClass = suiteClass;
6761
}
6862

69-
private static UniqueId requireNoCycles(UniqueId id) {
70-
// @formatter:off
71-
boolean containsCycle = id.getSegments().stream()
72-
.filter(segment -> SuiteTestDescriptor.SEGMENT_TYPE.equals(segment.getType()))
73-
.map(Segment::getValue)
74-
.collect(groupingBy(identity(), counting()))
75-
.values()
76-
.stream()
77-
.anyMatch(count -> count > 1);
78-
// @formatter:on
79-
Supplier<String> message = () -> String.format(
80-
"Configuration error: The suite configuration may not contain a cycle [%s]", id);
81-
Preconditions.condition(!containsCycle, message);
82-
return id;
83-
}
84-
8563
private static Boolean getFailIfNoTests(Class<?> suiteClass) {
8664
// @formatter:off
8765
return findAnnotation(suiteClass, Suite.class)

platform-tests/src/test/java/org/junit/platform/suite/engine/SuiteEngineTests.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
2323
import static org.junit.platform.testkit.engine.EventConditions.test;
2424
import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
25+
import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message;
2526

2627
import org.junit.jupiter.api.Test;
2728
import org.junit.jupiter.engine.descriptor.ClassTestDescriptor;
@@ -39,7 +40,9 @@
3940
import org.junit.platform.suite.engine.testcases.SingleTestTestCase;
4041
import org.junit.platform.suite.engine.testcases.TaggedTestTestCase;
4142
import org.junit.platform.suite.engine.testsuites.AbstractSuite;
43+
import org.junit.platform.suite.engine.testsuites.CyclicSuite;
4244
import org.junit.platform.suite.engine.testsuites.DynamicSuite;
45+
import org.junit.platform.suite.engine.testsuites.EmptyCyclicSuite;
4346
import org.junit.platform.suite.engine.testsuites.EmptyDynamicTestSuite;
4447
import org.junit.platform.suite.engine.testsuites.EmptyDynamicTestWithFailIfNoTestFalseSuite;
4548
import org.junit.platform.suite.engine.testsuites.EmptyTestCaseSuite;
@@ -50,6 +53,7 @@
5053
import org.junit.platform.suite.engine.testsuites.SelectClassesSuite;
5154
import org.junit.platform.suite.engine.testsuites.SuiteDisplayNameSuite;
5255
import org.junit.platform.suite.engine.testsuites.SuiteSuite;
56+
import org.junit.platform.suite.engine.testsuites.ThreePartCyclicSuite;
5357
import org.junit.platform.testkit.engine.EngineTestKit;
5458

5559
/**
@@ -350,4 +354,42 @@ void pruneAfterPostDiscoveryFilters() {
350354
// @formatter:on
351355
}
352356

357+
@Test
358+
void cyclicSuite() {
359+
// @formatter:off
360+
EngineTestKit.engine(ENGINE_ID)
361+
.selectors(selectClass(CyclicSuite.class))
362+
.execute()
363+
.allEvents()
364+
.assertThatEvents()
365+
.haveExactly(1, event(test(SingleTestTestCase.class.getName()), finishedSuccessfully()));
366+
// @formatter:on
367+
}
368+
369+
@Test
370+
void emptyCyclicSuite() {
371+
// @formatter:off
372+
EngineTestKit.engine(ENGINE_ID)
373+
.selectors(selectClass(EmptyCyclicSuite.class))
374+
.execute()
375+
.allEvents()
376+
.assertThatEvents()
377+
.haveExactly(1, event(container(EmptyCyclicSuite.class), finishedWithFailure(message(
378+
"Suite [org.junit.platform.suite.engine.testsuites.EmptyCyclicSuite] did not discover any tests"
379+
))));
380+
// @formatter:on
381+
}
382+
383+
@Test
384+
void threePartCyclicSuite() {
385+
// @formatter:off
386+
EngineTestKit.engine(ENGINE_ID)
387+
.selectors(selectClass(ThreePartCyclicSuite.PartA.class))
388+
.execute()
389+
.allEvents()
390+
.assertThatEvents()
391+
.haveExactly(1, event(test(SingleTestTestCase.class.getName()), finishedSuccessfully()));
392+
// @formatter:on
393+
}
394+
353395
}

platform-tests/src/test/java/org/junit/platform/suite/engine/SuiteTestDescriptorTests.java

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,12 @@
2525
import org.junit.jupiter.engine.descriptor.ClassTestDescriptor;
2626
import org.junit.jupiter.engine.descriptor.JupiterEngineDescriptor;
2727
import org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor;
28-
import org.junit.platform.commons.JUnitException;
2928
import org.junit.platform.commons.PreconditionViolationException;
3029
import org.junit.platform.engine.ConfigurationParameters;
3130
import org.junit.platform.engine.TestDescriptor;
3231
import org.junit.platform.engine.UniqueId;
3332
import org.junit.platform.suite.api.Suite;
3433
import org.junit.platform.suite.engine.testcases.SingleTestTestCase;
35-
import org.junit.platform.suite.engine.testsuites.CyclicSuite;
3634
import org.junit.platform.suite.engine.testsuites.SelectClassesSuite;
3735

3836
/**
@@ -90,21 +88,6 @@ void discoveryPlanCanNotBeModifiedAfterDiscovery() {
9088
});
9189
}
9290

93-
@Test
94-
void suitesMayNotContainACycle() {
95-
// @formatter:off
96-
UniqueId expectedCycle = suiteId
97-
.append("engine", SuiteEngineDescriptor.ENGINE_ID)
98-
.append(SuiteTestDescriptor.SEGMENT_TYPE, CyclicSuite.class.getName())
99-
.append("engine", SuiteEngineDescriptor.ENGINE_ID)
100-
.append(SuiteTestDescriptor.SEGMENT_TYPE, CyclicSuite.class.getName());
101-
// @formatter:on
102-
suite.addDiscoveryRequestFrom(CyclicSuite.class);
103-
JUnitException exception = assertThrows(JUnitException.class, suite::discover);
104-
assertEquals("Configuration error: The suite configuration may not contain a cycle [" + expectedCycle + "]",
105-
exception.getCause().getCause().getCause().getMessage());
106-
}
107-
10891
@Test
10992
void suiteMayRegisterTests() {
11093
assertTrue(suite.mayRegisterTests());

platform-tests/src/test/java/org/junit/platform/suite/engine/testsuites/CyclicSuite.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
import org.junit.platform.suite.api.IncludeClassNamePatterns;
1414
import org.junit.platform.suite.api.SelectClasses;
1515
import org.junit.platform.suite.api.Suite;
16+
import org.junit.platform.suite.engine.testcases.SingleTestTestCase;
1617

1718
/**
1819
* @since 1.8
1920
*/
2021
@Suite
2122
@IncludeClassNamePatterns(".*")
22-
@SelectClasses(CyclicSuite.class)
23+
@SelectClasses({ CyclicSuite.class, SingleTestTestCase.class })
2324
public class CyclicSuite {
2425
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2015-2022 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.suite.engine.testsuites;
12+
13+
import org.junit.platform.suite.api.IncludeClassNamePatterns;
14+
import org.junit.platform.suite.api.SelectClasses;
15+
import org.junit.platform.suite.api.Suite;
16+
17+
/**
18+
* @since 1.9.2
19+
*/
20+
@Suite
21+
@IncludeClassNamePatterns(".*")
22+
@SelectClasses(EmptyCyclicSuite.class)
23+
public class EmptyCyclicSuite {
24+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2015-2022 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.suite.engine.testsuites;
12+
13+
import org.junit.platform.suite.api.IncludeClassNamePatterns;
14+
import org.junit.platform.suite.api.SelectClasses;
15+
import org.junit.platform.suite.api.Suite;
16+
import org.junit.platform.suite.engine.testcases.SingleTestTestCase;
17+
18+
/**
19+
* @since 1.9.2
20+
*/
21+
public class ThreePartCyclicSuite {
22+
23+
@Suite
24+
@IncludeClassNamePatterns(".*")
25+
@SelectClasses({ PartB.class })
26+
public static class PartA {
27+
28+
}
29+
30+
@Suite
31+
@IncludeClassNamePatterns(".*")
32+
@SelectClasses({ PartC.class })
33+
public static class PartB {
34+
35+
}
36+
37+
@Suite
38+
@IncludeClassNamePatterns(".*")
39+
@SelectClasses({ PartB.class, SingleTestTestCase.class })
40+
public static class PartC {
41+
42+
}
43+
}

0 commit comments

Comments
 (0)