Skip to content

Commit b2e1f70

Browse files
authored
Fix #3303: distinguish JUnit 6 ParameterizedClass invocations (#3432)
* 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. * 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. * Fix #3303: parse UniqueId segments for class/method indexes Use UniqueId.parse().getSegments() instead of string scanning. Rebuild the reported name as [class][method] so a method-only legacy suffix is not treated as the class index.
1 parent c051938 commit b2e1f70

2 files changed

Lines changed: 282 additions & 2 deletions

File tree

surefire-providers/surefire-junit-platform/src/main/java/org/apache/maven/surefire/junitplatform/RunListenerAdapter.java

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,37 @@ private Stream<TestIdentifier> collectAllTestIdentifiersInHierarchy(TestIdentifi
283283
.orElseGet(Stream::empty);
284284
}
285285

286+
/**
287+
* Collect every {@code [class-template-invocation:#N]} / {@code [test-template-invocation:#N]}
288+
* index from the unique id. Nested {@code @ParameterizedClass} declarations emit more than
289+
* one class segment; taking only the last would collapse outer #1/inner #1 with outer #2/inner #1.
290+
*
291+
* @param uniqueId the platform unique id string
292+
* @param segmentType {@code class-template-invocation} or {@code test-template-invocation}
293+
* @return {@code [outer][inner]} or empty when the id has no such segment
294+
*/
295+
private static String extractInvocationIndexSuffix(String uniqueId, String segmentType) {
296+
if (uniqueId == null || uniqueId.isEmpty()) {
297+
return "";
298+
}
299+
try {
300+
StringBuilder suffix = new StringBuilder();
301+
for (UniqueId.Segment segment : UniqueId.parse(uniqueId).getSegments()) {
302+
if (!segmentType.equals(segment.getType())) {
303+
continue;
304+
}
305+
String value = segment.getValue();
306+
if (value.startsWith("#")) {
307+
value = value.substring(1);
308+
}
309+
suffix.append('[').append(value).append(']');
310+
}
311+
return suffix.toString();
312+
} catch (RuntimeException ignored) {
313+
return "";
314+
}
315+
}
316+
286317
private String safeGetMessage(Throwable throwable) {
287318
try {
288319
SafeThrowable t = throwable == null ? null : new SafeThrowable(throwable);
@@ -553,14 +584,37 @@ private ResultDisplay toClassMethodName(TestIdentifier testIdentifier) {
553584
.map(TestIdentifier::getLegacyReportingName)
554585
.anyMatch(legacyReportingName -> legacyReportingName.matches("^\\[.+]$"));
555586
boolean isTestTemplate = testIdentifier.getLegacyReportingName().matches("^.*\\[\\d+]$");
556-
557-
boolean parameterized = isParameterized || hasParameterizedParent || isTestTemplate;
587+
// JUnit 6 @ParameterizedClass parents have a ClassSource, so they are missed by
588+
// hasParameterizedParent, and the method legacy name no longer includes [N] (#3303).
589+
String uniqueId = testIdentifier.getUniqueId();
590+
String classTemplateInvocationSuffix = extractInvocationIndexSuffix(uniqueId, "class-template-invocation");
591+
String testTemplateInvocationSuffix = extractInvocationIndexSuffix(uniqueId, "test-template-invocation");
592+
593+
boolean parameterized = isParameterized
594+
|| hasParameterizedParent
595+
|| isTestTemplate
596+
|| !classTemplateInvocationSuffix.isEmpty();
558597
String methodName = methodSource.getMethodName();
559598
String description = testIdentifier.getLegacyReportingName();
560599
boolean equalDescriptions = methodDisplay.equals(description);
561600
boolean hasLegacyDescription = description.startsWith(methodName + '(');
562601
boolean hasDisplayName = !equalDescriptions || !hasLegacyDescription;
563602
String methodDesc = parameterized ? description : methodName;
603+
// Rebuild as [class][method]. A contains() check on the class index alone
604+
// treated foo()[2] (method #2) as already tagged for class #2, and
605+
// foo()[1] + class #2 became foo()[1][2] instead of foo()[2][1].
606+
if (!classTemplateInvocationSuffix.isEmpty()) {
607+
String desired = classTemplateInvocationSuffix + testTemplateInvocationSuffix;
608+
if (!methodDesc.endsWith(desired)) {
609+
if (!testTemplateInvocationSuffix.isEmpty() && methodDesc.endsWith(testTemplateInvocationSuffix)) {
610+
methodDesc =
611+
methodDesc.substring(0, methodDesc.length() - testTemplateInvocationSuffix.length())
612+
+ desired;
613+
} else {
614+
methodDesc = methodDesc + classTemplateInvocationSuffix;
615+
}
616+
}
617+
}
564618
String methodDisp = hasDisplayName ? methodDisplay : methodDesc;
565619

566620
// The behavior of methods getLegacyReportingName() and getDisplayName().

surefire-providers/surefire-junit-platform/src/test/java/org/apache/maven/surefire/junitplatform/RunListenerAdapterTest.java

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,178 @@ public void notifiedWithCompatibleNameForMethodWithArguments() throws Exception
239239
assertNull(entry.getStackTraceWriter());
240240
}
241241

242+
@Test
243+
public void distinguishedJUnit6ParameterizedClassInvocationsByIndex() throws Exception {
244+
// JUnit 6 @ParameterizedClass uses [class-template-invocation:#N] parents with a ClassSource.
245+
// The method's own legacy name has no [N] suffix (unlike JUnit 5.14 / @ParameterizedTest),
246+
// so invocations must still be reported under distinct names or rerunFailingTestsCount
247+
// aggregates a pass+fail pair as a flake (#3303).
248+
EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter");
249+
TestDescriptor classTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId());
250+
engine.addChild(classTemplate);
251+
252+
TestDescriptor invocation1 = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 1);
253+
TestDescriptor method1 = newUnparameterizedMethodDescriptor(invocation1.getUniqueId());
254+
classTemplate.addChild(invocation1);
255+
invocation1.addChild(method1);
256+
257+
TestDescriptor invocation2 = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 2);
258+
TestDescriptor method2 = newUnparameterizedMethodDescriptor(invocation2.getUniqueId());
259+
classTemplate.addChild(invocation2);
260+
invocation2.addChild(method2);
261+
262+
TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY);
263+
adapter.testPlanExecutionStarted(plan);
264+
265+
adapter.executionStarted(TestIdentifier.from(engine));
266+
adapter.executionStarted(TestIdentifier.from(classTemplate));
267+
adapter.executionStarted(TestIdentifier.from(invocation1));
268+
adapter.executionStarted(TestIdentifier.from(method1));
269+
adapter.executionFinished(TestIdentifier.from(method1), successful());
270+
adapter.executionFinished(TestIdentifier.from(invocation1), successful());
271+
adapter.executionStarted(TestIdentifier.from(invocation2));
272+
adapter.executionStarted(TestIdentifier.from(method2));
273+
adapter.executionFinished(TestIdentifier.from(method2), failed(new AssertionError("fail")));
274+
275+
ArgumentCaptor<ReportEntry> started = ArgumentCaptor.forClass(ReportEntry.class);
276+
verify(listener, times(2)).testStarting(started.capture());
277+
assertEquals(
278+
MY_TEST_METHOD_NAME + "()[1]", started.getAllValues().get(0).getName());
279+
assertEquals(
280+
MY_TEST_METHOD_NAME + "()[2]", started.getAllValues().get(1).getName());
281+
282+
ArgumentCaptor<ReportEntry> failed = ArgumentCaptor.forClass(ReportEntry.class);
283+
verify(listener).testFailed(failed.capture());
284+
assertEquals(MY_TEST_METHOD_NAME + "()[2]", failed.getValue().getName());
285+
assertThat(failed.getValue().getName())
286+
.isNotEqualTo(started.getAllValues().get(0).getName());
287+
}
288+
289+
@Test
290+
public void distinguishedNestedJUnit6ParameterizedClassInvocations() throws Exception {
291+
EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter");
292+
TestDescriptor outerTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId());
293+
engine.addChild(outerTemplate);
294+
295+
TestDescriptor outer1 = newParameterizedClassInvocationDescriptor(outerTemplate.getUniqueId(), 1);
296+
TestDescriptor innerTemplate1 = newParameterizedClassTemplateDescriptor(outer1.getUniqueId());
297+
TestDescriptor inner1 = newParameterizedClassInvocationDescriptor(innerTemplate1.getUniqueId(), 1);
298+
TestDescriptor method11 = newUnparameterizedMethodDescriptor(inner1.getUniqueId());
299+
outerTemplate.addChild(outer1);
300+
outer1.addChild(innerTemplate1);
301+
innerTemplate1.addChild(inner1);
302+
inner1.addChild(method11);
303+
304+
TestDescriptor outer2 = newParameterizedClassInvocationDescriptor(outerTemplate.getUniqueId(), 2);
305+
TestDescriptor innerTemplate2 = newParameterizedClassTemplateDescriptor(outer2.getUniqueId());
306+
TestDescriptor inner2 = newParameterizedClassInvocationDescriptor(innerTemplate2.getUniqueId(), 1);
307+
TestDescriptor method21 = newUnparameterizedMethodDescriptor(inner2.getUniqueId());
308+
outerTemplate.addChild(outer2);
309+
outer2.addChild(innerTemplate2);
310+
innerTemplate2.addChild(inner2);
311+
inner2.addChild(method21);
312+
313+
TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY);
314+
adapter.testPlanExecutionStarted(plan);
315+
316+
adapter.executionStarted(TestIdentifier.from(engine));
317+
adapter.executionStarted(TestIdentifier.from(outerTemplate));
318+
adapter.executionStarted(TestIdentifier.from(outer1));
319+
adapter.executionStarted(TestIdentifier.from(innerTemplate1));
320+
adapter.executionStarted(TestIdentifier.from(inner1));
321+
adapter.executionStarted(TestIdentifier.from(method11));
322+
adapter.executionFinished(TestIdentifier.from(method11), successful());
323+
adapter.executionStarted(TestIdentifier.from(outer2));
324+
adapter.executionStarted(TestIdentifier.from(innerTemplate2));
325+
adapter.executionStarted(TestIdentifier.from(inner2));
326+
adapter.executionStarted(TestIdentifier.from(method21));
327+
adapter.executionFinished(TestIdentifier.from(method21), failed(new AssertionError("fail")));
328+
329+
ArgumentCaptor<ReportEntry> started = ArgumentCaptor.forClass(ReportEntry.class);
330+
verify(listener, times(2)).testStarting(started.capture());
331+
assertEquals(
332+
MY_TEST_METHOD_NAME + "()[1][1]", started.getAllValues().get(0).getName());
333+
assertEquals(
334+
MY_TEST_METHOD_NAME + "()[2][1]", started.getAllValues().get(1).getName());
335+
}
336+
337+
@Test
338+
public void doesNotReappendClassIndexWhenLegacyNameAlreadyHasIt() throws Exception {
339+
// JUnit 6.1+ parameterized methods already report method()[class][method].
340+
EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter");
341+
TestDescriptor classTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId());
342+
engine.addChild(classTemplate);
343+
344+
TestDescriptor invocation = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 1);
345+
TestDescriptor method = newLegacyIndexedMethodDescriptor(invocation.getUniqueId(), "()[1][2]", 2);
346+
classTemplate.addChild(invocation);
347+
invocation.addChild(method);
348+
349+
TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY);
350+
adapter.testPlanExecutionStarted(plan);
351+
352+
adapter.executionStarted(TestIdentifier.from(engine));
353+
adapter.executionStarted(TestIdentifier.from(classTemplate));
354+
adapter.executionStarted(TestIdentifier.from(invocation));
355+
adapter.executionStarted(TestIdentifier.from(method));
356+
adapter.executionFinished(TestIdentifier.from(method), successful());
357+
358+
ArgumentCaptor<ReportEntry> started = ArgumentCaptor.forClass(ReportEntry.class);
359+
verify(listener).testStarting(started.capture());
360+
assertEquals(MY_TEST_METHOD_NAME + "()[1][2]", started.getValue().getName());
361+
}
362+
363+
@Test
364+
public void classIndexGoesBeforeMethodIndexWhenLegacyNameOnlyHasMethodIndex() throws Exception {
365+
// JUnit 6.0 @ParameterizedTest under @ParameterizedClass: legacy is foo()[m] only.
366+
EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter");
367+
TestDescriptor classTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId());
368+
engine.addChild(classTemplate);
369+
370+
TestDescriptor invocation = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 2);
371+
TestDescriptor method = newLegacyIndexedMethodDescriptor(invocation.getUniqueId(), "()[1]", 1);
372+
classTemplate.addChild(invocation);
373+
invocation.addChild(method);
374+
375+
TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY);
376+
adapter.testPlanExecutionStarted(plan);
377+
378+
adapter.executionStarted(TestIdentifier.from(engine));
379+
adapter.executionStarted(TestIdentifier.from(classTemplate));
380+
adapter.executionStarted(TestIdentifier.from(invocation));
381+
adapter.executionStarted(TestIdentifier.from(method));
382+
adapter.executionFinished(TestIdentifier.from(method), successful());
383+
384+
ArgumentCaptor<ReportEntry> started = ArgumentCaptor.forClass(ReportEntry.class);
385+
verify(listener).testStarting(started.capture());
386+
assertEquals(MY_TEST_METHOD_NAME + "()[2][1]", started.getValue().getName());
387+
}
388+
389+
@Test
390+
public void classAndMethodIndexOnTheDiagonalStayDistinct() throws Exception {
391+
EngineDescriptor engine = new EngineDescriptor(UniqueId.forEngine("junit-jupiter"), "JUnit Jupiter");
392+
TestDescriptor classTemplate = newParameterizedClassTemplateDescriptor(engine.getUniqueId());
393+
engine.addChild(classTemplate);
394+
395+
TestDescriptor invocation = newParameterizedClassInvocationDescriptor(classTemplate.getUniqueId(), 2);
396+
TestDescriptor method = newLegacyIndexedMethodDescriptor(invocation.getUniqueId(), "()[2]", 2);
397+
classTemplate.addChild(invocation);
398+
invocation.addChild(method);
399+
400+
TestPlan plan = TestPlan.from(false, singletonList(engine), CONFIG_PARAMS, OUTPUT_DIRECTORY);
401+
adapter.testPlanExecutionStarted(plan);
402+
403+
adapter.executionStarted(TestIdentifier.from(engine));
404+
adapter.executionStarted(TestIdentifier.from(classTemplate));
405+
adapter.executionStarted(TestIdentifier.from(invocation));
406+
adapter.executionStarted(TestIdentifier.from(method));
407+
adapter.executionFinished(TestIdentifier.from(method), successful());
408+
409+
ArgumentCaptor<ReportEntry> started = ArgumentCaptor.forClass(ReportEntry.class);
410+
verify(listener).testStarting(started.capture());
411+
assertEquals(MY_TEST_METHOD_NAME + "()[2][2]", started.getValue().getName());
412+
}
413+
242414
@Test
243415
public void notifiedEagerlyForTestSetWhenClassExecutionStarted() throws Exception {
244416
EngineDescriptor engine = newEngineDescriptor();
@@ -973,6 +1145,60 @@ public Type getType() {
9731145
assertEquals("Run a dummy cucumber test", entry.getName());
9741146
}
9751147

1148+
private static TestDescriptor newParameterizedClassTemplateDescriptor(UniqueId engineId) {
1149+
return new ClassTestDescriptor(
1150+
engineId.append("class-template", MyTestClass.class.getName()),
1151+
MyTestClass.class,
1152+
new DefaultJupiterConfiguration(CONFIG_PARAMS, OUTPUT_DIRECTORY));
1153+
}
1154+
1155+
private static TestDescriptor newParameterizedClassInvocationDescriptor(UniqueId classTemplateId, int index) {
1156+
return new AbstractTestDescriptor(
1157+
classTemplateId.append("class-template-invocation", "#" + index),
1158+
"Parameterization with index: [" + index + "]",
1159+
ClassSource.from(MyTestClass.class)) {
1160+
@Override
1161+
public Type getType() {
1162+
return CONTAINER;
1163+
}
1164+
1165+
@Override
1166+
public String getLegacyReportingName() {
1167+
return MyTestClass.class.getSimpleName() + "[" + index + "]";
1168+
}
1169+
};
1170+
}
1171+
1172+
private static TestDescriptor newUnparameterizedMethodDescriptor(UniqueId parentId) throws Exception {
1173+
return newLegacyIndexedMethodDescriptor(parentId, "()");
1174+
}
1175+
1176+
private static TestDescriptor newLegacyIndexedMethodDescriptor(UniqueId parentId, String legacySuffix)
1177+
throws Exception {
1178+
return newLegacyIndexedMethodDescriptor(parentId, legacySuffix, 0);
1179+
}
1180+
1181+
private static TestDescriptor newLegacyIndexedMethodDescriptor(
1182+
UniqueId parentId, String legacySuffix, int testTemplateInvocationIndex) throws Exception {
1183+
Method method = MyTestClass.class.getDeclaredMethod(MY_TEST_METHOD_NAME);
1184+
UniqueId methodId = parentId.append("method", method.getName() + "()");
1185+
if (testTemplateInvocationIndex > 0) {
1186+
methodId = methodId.append("test-template-invocation", "#" + testTemplateInvocationIndex);
1187+
}
1188+
return new AbstractTestDescriptor(
1189+
methodId, method.getName() + "()", MethodSource.from(MyTestClass.class, method)) {
1190+
@Override
1191+
public Type getType() {
1192+
return TEST;
1193+
}
1194+
1195+
@Override
1196+
public String getLegacyReportingName() {
1197+
return method.getName() + legacySuffix;
1198+
}
1199+
};
1200+
}
1201+
9761202
private static TestIdentifier newMethodIdentifier() throws Exception {
9771203
return TestIdentifier.from(newMethodDescriptor());
9781204
}

0 commit comments

Comments
 (0)