From 878db1dd0f6d9a321181b2d6a8adb2d6f0557970 Mon Sep 17 00:00:00 2001 From: Mirko Alicastro Date: Wed, 8 Apr 2026 20:24:06 +0200 Subject: [PATCH 1/2] Fixes #3264 : tests inside @Suite incorrectly classified as flakes --- .../its/jiras/Surefire1787JUnit45IT.java | 2 +- .../junitplatform/RunListenerAdapter.java | 24 +++++++- .../junitplatform/RunListenerAdapterTest.java | 58 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/surefire-its/src/test/java/org/apache/maven/surefire/its/jiras/Surefire1787JUnit45IT.java b/surefire-its/src/test/java/org/apache/maven/surefire/its/jiras/Surefire1787JUnit45IT.java index 89b1398577..ce96fbe55a 100644 --- a/surefire-its/src/test/java/org/apache/maven/surefire/its/jiras/Surefire1787JUnit45IT.java +++ b/surefire-its/src/test/java/org/apache/maven/surefire/its/jiras/Surefire1787JUnit45IT.java @@ -143,7 +143,7 @@ public void junit5Suite() throws Exception { .verifyTextInLog("Running pkg.domain.AxTest") .assertThatLogLine(containsString("Running pkg.domain.BxTest"), equalTo(0)); - TestFile xmlReportFile = outputValidator.getSurefireReportsXmlFile("TEST-pkg.JUnit5Tests.xml"); + TestFile xmlReportFile = outputValidator.getSurefireReportsXmlFile("TEST-pkg.domain.AxTest.xml"); xmlReportFile.assertFileExists(); Source source = Input.fromFile(xmlReportFile.getFile()).build(); diff --git a/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java b/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java index 9c885e5013..455afbb2db 100644 --- a/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java +++ b/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java @@ -311,7 +311,29 @@ private TestIdentifier findTopParent(TestIdentifier testIdentifier) { // use deprecated method testPlan.getTestIdentifier( testIdentifier.getParentIdObject().get().toString()); - return !parent.getParentIdObject().isPresent() ? testIdentifier : findTopParent(parent); + if (!parent.getParentIdObject().isPresent()) { + return testIdentifier; + } + // Stop traversing at engine boundaries. When a test runs inside a Suite, + // the hierarchy contains a nested engine (e.g., junit-jupiter under junit-platform-suite). + // Without this check, tests would be incorrectly attributed to the Suite class + // instead of their actual test class. + if (isEngineIdentifier(parent)) { + return testIdentifier; + } + return findTopParent(parent); + } + + private static boolean isEngineIdentifier(TestIdentifier testIdentifier) { + String uniqueId = testIdentifier.getUniqueId(); + int lastOpen = uniqueId.lastIndexOf('['); + if (lastOpen >= 0) { + int colon = uniqueId.indexOf(':', lastOpen); + if (colon > lastOpen) { + return "engine".equals(uniqueId.substring(lastOpen + 1, colon)); + } + } + return false; } /** diff --git a/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java b/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java index 9e5c2fb354..115b8730c6 100644 --- a/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java +++ b/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java @@ -602,6 +602,62 @@ public void displayNamesIgnoredInReport() throws NoSuchMethodException { assertEquals("some display name", value.getNameText()); } + @Test + public void notifiedWithActualTestClassNameWhenRunInsideSuite() throws Exception { + // Build a Suite hierarchy: SuiteEngine -> SuiteClass -> JupiterEngine -> TestClass -> method + // This simulates @Suite @SelectPackages("...") running tests from another class. + EngineDescriptor suiteEngine = + new EngineDescriptor(UniqueId.forEngine("junit-platform-suite"), "JUnit Platform Suite"); + + TestDescriptor suiteClass = new ClassTestDescriptor( + suiteEngine.getUniqueId().append("suite", MySuiteClass.class.getName()), + MySuiteClass.class, + new DefaultJupiterConfiguration(CONFIG_PARAMS, OUTPUT_DIRECTORY)); + suiteEngine.addChild(suiteClass); + + TestDescriptor jupiterEngine = + new AbstractTestDescriptor( + suiteClass.getUniqueId().append("engine", "junit-jupiter"), "JUnit Jupiter") { + @Override + public Type getType() { + return Type.CONTAINER; + } + }; + suiteClass.addChild(jupiterEngine); + + TestDescriptor testClass = new ClassTestDescriptor( + jupiterEngine.getUniqueId().append("class", MyTestClass.class.getName()), + MyTestClass.class, + new DefaultJupiterConfiguration(CONFIG_PARAMS, OUTPUT_DIRECTORY)); + jupiterEngine.addChild(testClass); + + TestDescriptor method = new TestMethodTestDescriptor( + testClass.getUniqueId().append("method", MY_TEST_METHOD_NAME), + MyTestClass.class, + MyTestClass.class.getDeclaredMethod(MY_TEST_METHOD_NAME), + Collections::emptyList, + new DefaultJupiterConfiguration(CONFIG_PARAMS, OUTPUT_DIRECTORY)); + testClass.addChild(method); + + TestPlan plan = TestPlan.from(false, singletonList(suiteEngine), CONFIG_PARAMS, OUTPUT_DIRECTORY); + adapter.testPlanExecutionStarted(plan); + + adapter.executionStarted(TestIdentifier.from(suiteEngine)); + adapter.executionStarted(TestIdentifier.from(suiteClass)); + adapter.executionStarted(TestIdentifier.from(jupiterEngine)); + adapter.executionStarted(TestIdentifier.from(testClass)); + adapter.executionStarted(TestIdentifier.from(method)); + + ArgumentCaptor entryCaptor = ArgumentCaptor.forClass(ReportEntry.class); + adapter.executionFinished(TestIdentifier.from(method), failed(new AssertionError("fail"))); + verify(listener).testFailed(entryCaptor.capture()); + + ReportEntry entry = entryCaptor.getValue(); + // The source name must be the actual test class, not the Suite class + assertEquals(MyTestClass.class.getName(), entry.getSourceName()); + assertEquals(MY_TEST_METHOD_NAME, entry.getName()); + } + private static TestIdentifier newMethodIdentifier() throws Exception { return TestIdentifier.from(newMethodDescriptor()); } @@ -720,6 +776,8 @@ void myTestMethod(String foo) {} void myNamedTestMethod() {} } + private static class MySuiteClass {} + static class TestMethodTestDescriptorWithDisplayName extends AbstractTestDescriptor { private TestMethodTestDescriptorWithDisplayName( UniqueId uniqueId, Class testClass, Method testMethod, String displayName) { From 815c39259508b9ed7873a042d81482f7f2e86d57 Mon Sep 17 00:00:00 2001 From: Mirko Alicastro Date: Tue, 2 Jun 2026 22:04:03 +0200 Subject: [PATCH 2/2] Attributes suite tests to enclosing class when nested engines expose no test class --- .../junitplatform/RunListenerAdapter.java | 15 +++-- .../junitplatform/RunListenerAdapterTest.java | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java b/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java index 455afbb2db..f5f019a3d6 100644 --- a/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java +++ b/surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java @@ -314,16 +314,21 @@ private TestIdentifier findTopParent(TestIdentifier testIdentifier) { if (!parent.getParentIdObject().isPresent()) { return testIdentifier; } - // Stop traversing at engine boundaries. When a test runs inside a Suite, - // the hierarchy contains a nested engine (e.g., junit-jupiter under junit-platform-suite). - // Without this check, tests would be incorrectly attributed to the Suite class - // instead of their actual test class. - if (isEngineIdentifier(parent)) { + // Inside a Suite the hierarchy contains a nested engine (like junit-jupiter under + // junit-platform-suite). Stop at that boundary so the test is attributed to its real test + // class rather than the Suite class. The ClassSource guard keeps traversing up for engines + // that expose no test class below them (like Cucumber features/scenarios), so those tests + // fall back to the enclosing Suite class instead of being dropped. + if (isEngineIdentifier(parent) && hasClassSource(testIdentifier)) { return testIdentifier; } return findTopParent(parent); } + private static boolean hasClassSource(TestIdentifier testIdentifier) { + return testIdentifier.getSource().filter(ClassSource.class::isInstance).isPresent(); + } + private static boolean isEngineIdentifier(TestIdentifier testIdentifier) { String uniqueId = testIdentifier.getUniqueId(); int lastOpen = uniqueId.lastIndexOf('['); diff --git a/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java b/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java index 115b8730c6..1c0b8ce7f8 100644 --- a/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java +++ b/surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java @@ -658,6 +658,67 @@ public Type getType() { assertEquals(MY_TEST_METHOD_NAME, entry.getName()); } + @Test + public void notifiedWithSuiteClassNameWhenEngineHasNoTestClass() { + // Build a Suite hierarchy whose nested engine exposes no test class right below it, as + // Cucumber does: SuiteEngine -> SuiteClass -> CucumberEngine -> feature -> scenario. + // The feature/scenario nodes carry no ClassSource, so the test must be attributed to the + // enclosing Suite class instead of being dropped (see issue #3264 follow-up / Cucumber IT). + EngineDescriptor suiteEngine = + new EngineDescriptor(UniqueId.forEngine("junit-platform-suite"), "JUnit Platform Suite"); + + TestDescriptor suiteClass = new ClassTestDescriptor( + suiteEngine.getUniqueId().append("suite", MySuiteClass.class.getName()), + MySuiteClass.class, + new DefaultJupiterConfiguration(CONFIG_PARAMS, OUTPUT_DIRECTORY)); + suiteEngine.addChild(suiteClass); + + TestDescriptor cucumberEngine = + new AbstractTestDescriptor(suiteClass.getUniqueId().append("engine", "cucumber"), "Cucumber") { + @Override + public Type getType() { + return Type.CONTAINER; + } + }; + suiteClass.addChild(cucumberEngine); + + TestDescriptor feature = + new AbstractTestDescriptor(cucumberEngine.getUniqueId().append("feature", "sum.feature"), "Sum test") { + @Override + public Type getType() { + return Type.CONTAINER; + } + }; + cucumberEngine.addChild(feature); + + TestDescriptor scenario = + new AbstractTestDescriptor(feature.getUniqueId().append("scenario", "1"), "Invalid test") { + @Override + public Type getType() { + return Type.TEST; + } + }; + feature.addChild(scenario); + + TestPlan plan = TestPlan.from(false, singletonList(suiteEngine), CONFIG_PARAMS, OUTPUT_DIRECTORY); + adapter.testPlanExecutionStarted(plan); + + adapter.executionStarted(TestIdentifier.from(suiteEngine)); + adapter.executionStarted(TestIdentifier.from(suiteClass)); + adapter.executionStarted(TestIdentifier.from(cucumberEngine)); + adapter.executionStarted(TestIdentifier.from(feature)); + adapter.executionStarted(TestIdentifier.from(scenario)); + + ArgumentCaptor entryCaptor = ArgumentCaptor.forClass(ReportEntry.class); + adapter.executionFinished(TestIdentifier.from(scenario), failed(new AssertionError("fail"))); + verify(listener).testFailed(entryCaptor.capture()); + + ReportEntry entry = entryCaptor.getValue(); + // No test class exists below the Cucumber engine, so the Suite class is the attribution. + assertEquals(MySuiteClass.class.getName(), entry.getSourceName()); + assertEquals("Invalid test", entry.getName()); + } + private static TestIdentifier newMethodIdentifier() throws Exception { return TestIdentifier.from(newMethodDescriptor()); }