From a7dd1ffdf8bd0dd9f7d64c689f3193f856511aee Mon Sep 17 00:00:00 2001 From: Abdullah <89297042+AzazelSensei@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:38:18 +0300 Subject: [PATCH 1/2] Fix #3303: keep JUnit 6 ParameterizedClass invocations distinct @ParameterizedClass uses a [class-template-invocation:#N] unique-id segment and a ClassSource parent. The method legacy name no longer carries [N], so rerunFailingTestsCount merged a pass+fail pair into one flaky name. Append that index to the reported method name, same as we already do for @ParameterizedTest. --- .../junitplatform/RunListenerAdapter.java | 36 +++++++- .../junitplatform/RunListenerAdapterTest.java | 91 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) 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 ba6c3d0c53..074aa9a5ee 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 @@ -208,6 +208,32 @@ private Stream collectAllTestIdentifiersInHierarchy(TestIdentifi .orElseGet(Stream::empty); } + /** + * Extract the invocation index from a JUnit Jupiter {@code [class-template-invocation:#N]} unique-id + * segment. JUnit 6 {@code @ParameterizedClass} uses this segment; the method-level legacy name + * no longer carries the {@code [N]} suffix that {@code @ParameterizedTest} still has. + * + * @param uniqueId the platform unique id string + * @return the numeric index, or {@code null} when the id has no class-template invocation + */ + private static String extractClassTemplateInvocationIndex(String uniqueId) { + if (uniqueId == null) { + return null; + } + String marker = "[class-template-invocation:"; + int start = uniqueId.lastIndexOf(marker); + if (start < 0) { + return null; + } + start += marker.length(); + int end = uniqueId.indexOf(']', start); + if (end <= start) { + return null; + } + String value = uniqueId.substring(start, end); + return value.startsWith("#") ? value.substring(1) : value; + } + private String safeGetMessage(Throwable throwable) { try { SafeThrowable t = throwable == null ? null : new SafeThrowable(throwable); @@ -462,14 +488,22 @@ private ResultDisplay toClassMethodName(TestIdentifier testIdentifier) { .map(TestIdentifier::getLegacyReportingName) .anyMatch(legacyReportingName -> legacyReportingName.matches("^\\[.+]$")); boolean isTestTemplate = testIdentifier.getLegacyReportingName().matches("^.*\\[\\d+]$"); + // JUnit 6 @ParameterizedClass parents have a ClassSource, so they are missed by + // hasParameterizedParent, and the method legacy name no longer includes [N] (#3303). + String classTemplateInvocationIndex = extractClassTemplateInvocationIndex(testIdentifier.getUniqueId()); - boolean parameterized = isParameterized || hasParameterizedParent || isTestTemplate; + boolean parameterized = + isParameterized || hasParameterizedParent || isTestTemplate || classTemplateInvocationIndex != null; String methodName = methodSource.getMethodName(); String description = testIdentifier.getLegacyReportingName(); boolean equalDescriptions = methodDisplay.equals(description); boolean hasLegacyDescription = description.startsWith(methodName + '('); boolean hasDisplayName = !equalDescriptions || !hasLegacyDescription; String methodDesc = parameterized ? description : methodName; + if (classTemplateInvocationIndex != null + && !methodDesc.endsWith("[" + classTemplateInvocationIndex + "]")) { + methodDesc = methodDesc + "[" + classTemplateInvocationIndex + "]"; + } String methodDisp = hasDisplayName ? methodDisplay : methodDesc; // The behavior of methods getLegacyReportingName() and getDisplayName(). 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 e5c58bbcf4..f32c64233e 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 @@ -72,6 +72,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -142,6 +143,53 @@ public void notifiedWithCompatibleNameForMethodWithArguments() throws Exception assertNull(entry.getStackTraceWriter()); } + @Test + public void distinguishedJUnit6ParameterizedClassInvocationsByIndex() throws Exception { + // JUnit 6 @ParameterizedClass uses [class-template-invocation:#N] parents with a ClassSource. + // The method's own legacy name has no [N] suffix (unlike JUnit 5.14 / @ParameterizedTest), + // so invocations must still be reported under distinct names or rerunFailingTestsCount + // aggregates a pass+fail pair as a flake (#3303). + EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter"); + TestDescriptor classTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId()); + engine.addChild(classTemplate); + + TestDescriptor invocation1 = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 1); + TestDescriptor method1 = newUnparameterizedMethodDescriptor(invocation1.getUniqueId()); + classTemplate.addChild(invocation1); + invocation1.addChild(method1); + + TestDescriptor invocation2 = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 2); + TestDescriptor method2 = newUnparameterizedMethodDescriptor(invocation2.getUniqueId()); + classTemplate.addChild(invocation2); + invocation2.addChild(method2); + + TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY); + adapter.testPlanExecutionStarted(plan); + + adapter.executionStarted(TestIdentifier.from(engine)); + adapter.executionStarted(TestIdentifier.from(classTemplate)); + adapter.executionStarted(TestIdentifier.from(invocation1)); + adapter.executionStarted(TestIdentifier.from(method1)); + adapter.executionFinished(TestIdentifier.from(method1), successful()); + adapter.executionFinished(TestIdentifier.from(invocation1), successful()); + adapter.executionStarted(TestIdentifier.from(invocation2)); + adapter.executionStarted(TestIdentifier.from(method2)); + adapter.executionFinished(TestIdentifier.from(method2), failed(new AssertionError("fail"))); + + ArgumentCaptor started = ArgumentCaptor.forClass(ReportEntry.class); + verify(listener, times(2)).testStarting(started.capture()); + assertEquals( + MY_TEST_METHOD_NAME + "()[1]", started.getAllValues().get(0).getName()); + assertEquals( + MY_TEST_METHOD_NAME + "()[2]", started.getAllValues().get(1).getName()); + + ArgumentCaptor failed = ArgumentCaptor.forClass(ReportEntry.class); + verify(listener).testFailed(failed.capture()); + assertEquals(MY_TEST_METHOD_NAME + "()[2]", failed.getValue().getName()); + assertThat(failed.getValue().getName()) + .isNotEqualTo(started.getAllValues().get(0).getName()); + } + @Test public void notifiedEagerlyForTestSetWhenClassExecutionStarted() throws Exception { EngineDescriptor engine = newEngineDescriptor(); @@ -800,6 +848,49 @@ public Type getType() { assertEquals("Run a dummy cucumber test", entry.getName()); } + private static TestDescriptor newParameterizedClassTemplateDescriptor(UniqueId engineId) { + return new ClassTestDescriptor( + engineId.append("class-template", MyTestClass.class.getName()), + MyTestClass.class, + new DefaultJupiterConfiguration(CONFIG_PARAMS, OUTPUT_DIRECTORY)); + } + + private static TestDescriptor newParameterizedClassInvocationDescriptor(UniqueId classTemplateId, int index) { + return new AbstractTestDescriptor( + classTemplateId.append("class-template-invocation", "#" + index), + "Parameterization with index: [" + index + "]", + ClassSource.from(MyTestClass.class)) { + @Override + public Type getType() { + return CONTAINER; + } + + @Override + public String getLegacyReportingName() { + return MyTestClass.class.getSimpleName() + "[" + index + "]"; + } + }; + } + + private static TestDescriptor newUnparameterizedMethodDescriptor(UniqueId parentId) throws Exception { + Method method = MyTestClass.class.getDeclaredMethod(MY_TEST_METHOD_NAME); + return new AbstractTestDescriptor( + parentId.append("method", method.getName() + "()"), + method.getName() + "()", + MethodSource.from(MyTestClass.class, method)) { + @Override + public Type getType() { + return TEST; + } + + @Override + public String getLegacyReportingName() { + // JUnit 6 keeps the method legacy name without the class-invocation index. + return method.getName() + "()"; + } + }; + } + private static TestIdentifier newMethodIdentifier() throws Exception { return TestIdentifier.from(newMethodDescriptor()); } From 0931118f8ecf3cfa3a83560ef6a37446add25bb8 Mon Sep 17 00:00:00 2001 From: Abdullah <89297042+AzazelSensei@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:02:07 +0300 Subject: [PATCH 2/2] Fix #3303: keep nested ParameterizedClass indices Collect every class-template-invocation index so nested parameterizations stay distinct. Do not re-append when the legacy name already carries the class index. --- .../junitplatform/RunListenerAdapter.java | 58 ++++++++----- .../junitplatform/RunListenerAdapterTest.java | 82 ++++++++++++++++++- 2 files changed, 116 insertions(+), 24 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 074aa9a5ee..c769373908 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 @@ -209,29 +209,39 @@ private Stream collectAllTestIdentifiersInHierarchy(TestIdentifi } /** - * Extract the invocation index from a JUnit Jupiter {@code [class-template-invocation:#N]} unique-id - * segment. JUnit 6 {@code @ParameterizedClass} uses this segment; the method-level legacy name - * no longer carries the {@code [N]} suffix that {@code @ParameterizedTest} still has. + * Build a {@code [outer][inner]} suffix from every JUnit Jupiter + * {@code [class-template-invocation:#N]} unique-id segment. Nested + * {@code @ParameterizedClass} declarations emit more than one; taking only + * the last would collapse outer #1/inner #1 with outer #2/inner #1. * * @param uniqueId the platform unique id string - * @return the numeric index, or {@code null} when the id has no class-template invocation + * @return the suffix, or an empty string when the id has no class-template invocation */ - private static String extractClassTemplateInvocationIndex(String uniqueId) { + private static String extractClassTemplateInvocationSuffix(String uniqueId) { if (uniqueId == null) { - return null; + return ""; } String marker = "[class-template-invocation:"; - int start = uniqueId.lastIndexOf(marker); - if (start < 0) { - return null; - } - start += marker.length(); - int end = uniqueId.indexOf(']', start); - if (end <= start) { - return null; + StringBuilder suffix = new StringBuilder(); + int from = 0; + while (true) { + int start = uniqueId.indexOf(marker, from); + if (start < 0) { + break; + } + start += marker.length(); + int end = uniqueId.indexOf(']', start); + if (end <= start) { + break; + } + String value = uniqueId.substring(start, end); + if (value.startsWith("#")) { + value = value.substring(1); + } + suffix.append('[').append(value).append(']'); + from = end + 1; } - String value = uniqueId.substring(start, end); - return value.startsWith("#") ? value.substring(1) : value; + return suffix.toString(); } private String safeGetMessage(Throwable throwable) { @@ -490,19 +500,23 @@ private ResultDisplay toClassMethodName(TestIdentifier testIdentifier) { boolean isTestTemplate = testIdentifier.getLegacyReportingName().matches("^.*\\[\\d+]$"); // JUnit 6 @ParameterizedClass parents have a ClassSource, so they are missed by // hasParameterizedParent, and the method legacy name no longer includes [N] (#3303). - String classTemplateInvocationIndex = extractClassTemplateInvocationIndex(testIdentifier.getUniqueId()); + String classTemplateInvocationSuffix = extractClassTemplateInvocationSuffix(testIdentifier.getUniqueId()); - boolean parameterized = - isParameterized || hasParameterizedParent || isTestTemplate || classTemplateInvocationIndex != null; + boolean parameterized = isParameterized + || hasParameterizedParent + || isTestTemplate + || !classTemplateInvocationSuffix.isEmpty(); String methodName = methodSource.getMethodName(); String description = testIdentifier.getLegacyReportingName(); boolean equalDescriptions = methodDisplay.equals(description); boolean hasLegacyDescription = description.startsWith(methodName + '('); boolean hasDisplayName = !equalDescriptions || !hasLegacyDescription; String methodDesc = parameterized ? description : methodName; - if (classTemplateInvocationIndex != null - && !methodDesc.endsWith("[" + classTemplateInvocationIndex + "]")) { - methodDesc = methodDesc + "[" + classTemplateInvocationIndex + "]"; + // JUnit 6.1+ already puts the class index before a method invocation index + // (method()[1][2]); do not append again just because the name does not end + // with the last class index. + if (!classTemplateInvocationSuffix.isEmpty() && !methodDesc.contains(classTemplateInvocationSuffix)) { + methodDesc = methodDesc + classTemplateInvocationSuffix; } String methodDisp = hasDisplayName ? methodDisplay : methodDesc; 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 f32c64233e..7e31ac8e39 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 @@ -190,6 +190,80 @@ public void distinguishedJUnit6ParameterizedClassInvocationsByIndex() throws Exc .isNotEqualTo(started.getAllValues().get(0).getName()); } + @Test + public void distinguishedNestedJUnit6ParameterizedClassInvocations() throws Exception { + EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter"); + TestDescriptor outerTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId()); + engine.addChild(outerTemplate); + + TestDescriptor outer1 = newParameterizedClassInvocationDescriptor(outerTemplate.getUniqueId(), 1); + TestDescriptor innerTemplate1 = newParameterizedClassTemplateDescriptor(outer1.getUniqueId()); + TestDescriptor inner1 = newParameterizedClassInvocationDescriptor(innerTemplate1.getUniqueId(), 1); + TestDescriptor method11 = newUnparameterizedMethodDescriptor(inner1.getUniqueId()); + outerTemplate.addChild(outer1); + outer1.addChild(innerTemplate1); + innerTemplate1.addChild(inner1); + inner1.addChild(method11); + + TestDescriptor outer2 = newParameterizedClassInvocationDescriptor(outerTemplate.getUniqueId(), 2); + TestDescriptor innerTemplate2 = newParameterizedClassTemplateDescriptor(outer2.getUniqueId()); + TestDescriptor inner2 = newParameterizedClassInvocationDescriptor(innerTemplate2.getUniqueId(), 1); + TestDescriptor method21 = newUnparameterizedMethodDescriptor(inner2.getUniqueId()); + outerTemplate.addChild(outer2); + outer2.addChild(innerTemplate2); + innerTemplate2.addChild(inner2); + inner2.addChild(method21); + + TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY); + adapter.testPlanExecutionStarted(plan); + + adapter.executionStarted(TestIdentifier.from(engine)); + adapter.executionStarted(TestIdentifier.from(outerTemplate)); + adapter.executionStarted(TestIdentifier.from(outer1)); + adapter.executionStarted(TestIdentifier.from(innerTemplate1)); + adapter.executionStarted(TestIdentifier.from(inner1)); + adapter.executionStarted(TestIdentifier.from(method11)); + adapter.executionFinished(TestIdentifier.from(method11), successful()); + adapter.executionStarted(TestIdentifier.from(outer2)); + adapter.executionStarted(TestIdentifier.from(innerTemplate2)); + adapter.executionStarted(TestIdentifier.from(inner2)); + adapter.executionStarted(TestIdentifier.from(method21)); + adapter.executionFinished(TestIdentifier.from(method21), failed(new AssertionError("fail"))); + + ArgumentCaptor started = ArgumentCaptor.forClass(ReportEntry.class); + verify(listener, times(2)).testStarting(started.capture()); + assertEquals( + MY_TEST_METHOD_NAME + "()[1][1]", started.getAllValues().get(0).getName()); + assertEquals( + MY_TEST_METHOD_NAME + "()[2][1]", started.getAllValues().get(1).getName()); + } + + @Test + public void doesNotReappendClassIndexWhenLegacyNameAlreadyHasIt() throws Exception { + // JUnit 6.1+ parameterized methods already report method()[class][method]. + EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter"); + TestDescriptor classTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId()); + engine.addChild(classTemplate); + + TestDescriptor invocation = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 1); + TestDescriptor method = newLegacyIndexedMethodDescriptor(invocation.getUniqueId(), "()[1][2]"); + classTemplate.addChild(invocation); + invocation.addChild(method); + + TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY); + adapter.testPlanExecutionStarted(plan); + + adapter.executionStarted(TestIdentifier.from(engine)); + adapter.executionStarted(TestIdentifier.from(classTemplate)); + adapter.executionStarted(TestIdentifier.from(invocation)); + adapter.executionStarted(TestIdentifier.from(method)); + adapter.executionFinished(TestIdentifier.from(method), successful()); + + ArgumentCaptor started = ArgumentCaptor.forClass(ReportEntry.class); + verify(listener).testStarting(started.capture()); + assertEquals(MY_TEST_METHOD_NAME + "()[1][2]", started.getValue().getName()); + } + @Test public void notifiedEagerlyForTestSetWhenClassExecutionStarted() throws Exception { EngineDescriptor engine = newEngineDescriptor(); @@ -873,6 +947,11 @@ public String getLegacyReportingName() { } private static TestDescriptor newUnparameterizedMethodDescriptor(UniqueId parentId) throws Exception { + return newLegacyIndexedMethodDescriptor(parentId, "()"); + } + + private static TestDescriptor newLegacyIndexedMethodDescriptor(UniqueId parentId, String legacySuffix) + throws Exception { Method method = MyTestClass.class.getDeclaredMethod(MY_TEST_METHOD_NAME); return new AbstractTestDescriptor( parentId.append("method", method.getName() + "()"), @@ -885,8 +964,7 @@ public Type getType() { @Override public String getLegacyReportingName() { - // JUnit 6 keeps the method legacy name without the class-invocation index. - return method.getName() + "()"; + return method.getName() + legacySuffix; } }; }