Repository navigation
fix: discover inherited test methods when running child classes - #1940
Vinay Kumar Maheshwaram (sudovinay01) wants to merge 11 commits into
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
wenyt (@wenytang-ms) I have addressed your three review comments in commit 17e6b99, added regression coverage, and verified the Java reactor. Could you please take another look when you have a chance? |
| } | ||
|
|
||
| private String resolveMethodTestName(String handleId) throws CoreException { | ||
| private String resolveMethodTestName(String handleId, String executionClassName) throws CoreException { |
There was a problem hiding this comment.
If the base class comes from a dependency JAR, running an inherited test like base(TestInfo info) on its own fails here. getCompilationUnit() is null for a binary method, so we get Cannot get compilation unit of methodbase. Running the whole child class avoids this, but running or debugging just that method doesn't.
Could we get the parameter types from an IMethodBinding for binary methods instead of looking for source? We can leave the current source-method path as-is and keep using the child class for execution. A test with a compiled base class and a method parameter would catch this.
There was a problem hiding this comment.
Addressed in 57344f1. For parameterized JUnit 5/6 methods without a compilation unit, the launch resolver now uses ASTParser.createBindings() to obtain an IMethodBinding and formats its parameter types. The source-backed AST path remains unchanged, and the child class is still used as the execution target. Added a regression with a parent compiled to target/test-classes and a child project referencing it as a binary library; it verifies the selector example.InheritanceBinaryChildTest:base(org.junit.jupiter.api.TestInfo). The full Java reactor passes all 12 plugin tests.
There was a problem hiding this comment.
Problem / Question
- Duplicate code paths (
if (unit == null)vselse) & unnecessary AST traversal:
ASTParser.createBindings(new IJavaElement[] { method }, new NullProgressMonitor())works for both sourceIMethods (unit != null) and binaryIMethods (unit == null) in JDT. Keeping theelsebranch that callsTestSearchUtils.parseToAst(unit, false, ...)+ASTNodeSearchUtil.getMethodDeclarationNode+ loops overSingleVariableDeclarationAST nodes is redundant and leaves two parallel paths doing the same job. - Missing generic type erasure in
formatParameterType:
When an inherited test method comes from a generic parent class (e.g.,abstract class BaseTest<T> { @Test void base(T value) {} }),parameterType.isTypeVariable()istrue. In JDT (TypeBinding.getBinaryName()), callingparameterType.getBinaryName()directly on a type variableTreturns JDT's internal synthetic key (e.g.,pkg.BaseTest$T) instead of the runtime erased JVM class name (java.lang.Object), causing JUnit Platform'sDiscoverySelectors.selectMethodto fail withClassNotFoundExceptionat runtime. CallingparameterType.getErasure()first resolvesTto its runtime erased type (java.lang.Objector upper bound).
Solution
Unify both source and binary IMethods to use parser.createBindings(new IJavaElement[] { method }, ...) and call parameterType.getErasure() in formatParameterType:
private String resolveMethodTestName(String handleId, String executionClassName) throws CoreException {
final IMethod method = (IMethod) JavaCore.create(handleId);
String testName = method.getElementName();
if ((this.args.testKind == TestKind.JUnit5 || this.args.testKind == TestKind.JUnit6) &&
method.getParameters().length > 0) {
final ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setProject(method.getJavaProject());
final IBinding[] bindings = parser.createBindings(new IJavaElement[] { method },
new NullProgressMonitor());
if (bindings.length != 1 || !(bindings[0] instanceof IMethodBinding)) {
throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR,
"Cannot resolve method binding for " + method.getElementName(), null)); //$NON-NLS-1$
}
testName += formatParameterTypes((IMethodBinding) bindings[0]);
}
final String className = StringUtils.isNotBlank(executionClassName) ? executionClassName :
method.getDeclaringType().getFullyQualifiedName();
return className + ':' + testName;
}
private String formatParameterTypes(IMethodBinding methodBinding) {
final List<String> parameters = new LinkedList<>();
for (final ITypeBinding parameterType : methodBinding.getParameterTypes()) {
parameters.add(formatParameterType(parameterType));
}
return parameters.isEmpty() ? "" : "(" + String.join(",", parameters) + ")";
}
private String formatParameterType(ITypeBinding parameterType) {
final ITypeBinding erasedType = parameterType.getErasure();
return erasedType.isPrimitive() ? erasedType.getQualifiedName() : erasedType.getBinaryName();
}(You can also clean up the unused CompilationUnit, MethodDeclaration, SingleVariableDeclaration, and ASTNodeSearchUtil imports above.)
There was a problem hiding this comment.
Thanks for the follow-up and the concrete fix. I unified source and binary method parameter resolution through ASTParser.createBindings() and erase parameter types before constructing the JUnit selector. Regression coverage includes a generic source method and a generic method inherited from a compiled parent, preserving the child class as the execution target. I also ran the binary-inheritance fixture in Test Explorer: the inherited method passes on its own, and the complete child class passes 2/2.
|
wenyt (@wenytang-ms) I have also addressed your follow-up about inherited parameterized methods from dependency JARs in commit 57344f1. The new regression uses a compiled parent fixture and verifies the child-class launch selector; the full Java reactor passes all 12 plugin tests. When you have a chance, could you please re-review the updated PR? Thanks. |
|
Hi Jinbo Wang (@testforstephen) Changyong Gong (@chagong) wenyt (@wenytang-ms) — could one of you please review this PR when you have time? I’d appreciate feedback on the change and validation, and I’d like to make sure it aligns with the project’s expectations. Thanks for your time and review. |
| final IMethodBinding overridingMethod = moreSpecificMethods.get(key); | ||
| for (final TestFrameworkSearcher searcher : searchers) { | ||
| if (!searcher.isTestMethod(methodBinding)) { | ||
| continue; | ||
| } | ||
| if (overridingMethod != null && | ||
| (searcher.getTestKind() != TestKind.JUnit || searcher.isTestMethod(overridingMethod))) { | ||
| break; |
There was a problem hiding this comment.
Same signatures don't necessarily mean an override, e.g. a package-private parent method and a same-named child method in another package. Could we use IMethodBinding.overrides() instead, while preserving the JUnit 4 behavior?
// Keep all more-specific declarations, not one per signature.
final List<IMethodBinding> moreSpecificMethods = new LinkedList<>();
Collections.addAll(moreSpecificMethods, classBinding.getDeclaredMethods());
// Inside the searcher loop, replace the signature-based check:
boolean suppressed = moreSpecificMethods.stream().anyMatch(candidate ->
candidate.overrides(methodBinding)
&& (searcher.getTestKind() != TestKind.JUnit
|| searcher.isTestMethod(candidate)));
if (suppressed) {
break;
}
// After processing each superclass, before moving to its parent:
Collections.addAll(moreSpecificMethods, superClass.getDeclaredMethods());Please add a cross-package regression test and a three-level hierarchy test.
There was a problem hiding this comment.
Addressed in de1d289. Override suppression now uses IMethodBinding.overrides() across the full hierarchy, while checking that inherited methods are actually accessible from the child (including package-private visibility). Added a cross-package same-signature regression and extended the JUnit 4 fixture to a three-level hierarchy; the existing JUnit 4 unannotated-override behavior is preserved. The Java reactor verification passed after merging upstream/main.
There was a problem hiding this comment.
Thanks for the update! Looking at de1d289, there is a subtle difference between Java's method override rules (IMethodBinding.overrides) and JUnit 5/6's runtime test discovery, as well as a bug in how the for loop checks moreSpecificMethods. Here is a detailed breakdown of the two issues and the exact fix:
1. Problem: isInheritedBy() breaks JUnit 5/6 cross-package package-private @Test methods
In JUnit 5/6 (Jupiter), @Test methods are idiomatic package-private (no public or protected modifier). Even when a child test class is in a different package from its base class, the JUnit Platform runtime still discovers and executes the parent's package-private @Test methods via reflection.
Now consider your test case:
example.parent.PackagePrivateBaseTesthas@Test void packagePrivateTest()(package-private).example.child.CrossPackageSameSignatureTestextendsPackagePrivateBaseTestand declares an unannotatedvoid packagePrivateTest().
Under Java's override rules (JLS §8.4.8.1), a method in a different package does not override a package-private method in the superclass (childMethod.overrides(parentMethod) returns false). Because it is not an override, JUnit 5 still discovers and runs PackagePrivateBaseTest#packagePrivateTest() when executing CrossPackageSameSignatureTest.
However, isInheritedBy() rejects all package-private methods across packages, causing CrossPackageSameSignatureTest to discover 0 tests and breaking standard JUnit 5 cross-package inheritance. We only need to skip private methods (Modifier.isPrivate(methodBinding.getModifiers())).
2. Problem: Stopping at the first overrides() match in moreSpecificMethods + keeping inheritedTestSignatures
In addInheritedTestMethods (lines 591–597):
IMethodBinding overridingMethod = null;
for (final IMethodBinding moreSpecificMethod : moreSpecificMethods) {
if (moreSpecificMethod.overrides(methodBinding)) {
overridingMethod = moreSpecificMethod;
break;
}
}Consider the 3-level JUnit 4 hierarchy JUnit4BaseTest (@Test) -> JUnit4MiddleTest (@Test) -> JUnit4ChildTest (unannotated):
- When checking
JUnit4BaseTest#inherited(),moreSpecificMethodscontains[JUnit4ChildTest#inherited(), JUnit4MiddleTest#inherited()]. - Because the loop breaks on the first match,
overridingMethodis set toJUnit4ChildTest#inherited()(which has no@Testannotation) and ignoresJUnit4MiddleTest#inherited()(which does have@Testand was already added!). - That is why removing
inheritedTestSignatureswould have caused duplicate items with the currentfor ... breakloop, while keepinginheritedTestSignatures(keyed bygetMethodSignatureKey) deduplicates by erased name + parameter types without checkingoverrides().
Solution
You can remove isInheritedBy, getMethodSignatureKey, and inheritedTestSignatures completely, and use moreSpecificMethods.stream().anyMatch(...) to check whether any more-specific overriding method suppresses the ancestor method:
1. In TestSearchUtils.java, replace addInheritedTestMethods (and delete isInheritedBy and getMethodSignatureKey):
private static void addInheritedTestMethods(ITypeBinding classBinding, IType childType,
List<TestFrameworkSearcher> searchers, List<JavaTestItem> accumulator) throws JavaModelException {
if (classBinding == null || childType == null || searchers.isEmpty()) {
return;
}
final List<IMethodBinding> moreSpecificMethods = new LinkedList<>();
Collections.addAll(moreSpecificMethods, classBinding.getDeclaredMethods());
ITypeBinding superClass = classBinding.getSuperclass();
while (superClass != null && !"java.lang.Object".equals(superClass.getBinaryName())) {
for (final IMethodBinding methodBinding : superClass.getDeclaredMethods()) {
if (Modifier.isPrivate(methodBinding.getModifiers())) {
continue;
}
for (final TestFrameworkSearcher searcher : searchers) {
if (!searcher.isTestMethod(methodBinding)) {
continue;
}
final boolean suppressed = moreSpecificMethods.stream().anyMatch(candidate ->
candidate.overrides(methodBinding) &&
(searcher.getTestKind() != TestKind.JUnit || searcher.isTestMethod(candidate)));
if (suppressed) {
break;
}
final IJavaElement element = methodBinding.getJavaElement();
if (element == null) {
break;
}
final JavaTestItem methodItem = new JavaTestItemBuilder().setJavaElement(element)
.setLevel(TestLevel.METHOD)
.setKind(searcher.getTestKind())
.build();
rebaseMethodItemToChild(methodItem, childType);
accumulator.add(methodItem);
break;
}
}
Collections.addAll(moreSpecificMethods, superClass.getDeclaredMethods());
superClass = superClass.getSuperclass();
}
}2. In TestSearchUtilsTest.java, update the cross-package test expectation:
Because CrossPackageSameSignatureTest#packagePrivateTest() (in example.child) does not override PackagePrivateBaseTest#packagePrivateTest() (in example.parent), the parent's @Test method is not suppressed and should still be discovered:
@Test
public void testCrossPackageSameSignatureDoesNotSuppressPackagePrivateParentTest() throws Exception {
final IProject project = importProjects("preview-junit").get(0);
final IType childType = JavaCore.create(project)
.findType("example.child.CrossPackageSameSignatureTest");
assertNotNull(childType);
final List<JavaTestItem> methods = TestSearchUtils.findDirectTestChildrenForClass(
Arrays.asList(childType.getHandleIdentifier()), new NullProgressMonitor());
assertEquals(1, methods.size());
assertEquals("example.child.CrossPackageSameSignatureTest#packagePrivateTest()",
methods.get(0).getFullName());
}There was a problem hiding this comment.
Thanks for taking another look. The follow-up is addressed in the pushed update: discovery skips only private superclass methods, uses IMethodBinding.overrides() rather than signature equality, and evaluates all more-specific declarations for framework-specific suppression. Tests cover a same-signature package-private method across packages and the three-level JUnit 4 case where an annotated middle override remains discoverable despite an unannotated child override.
…methods-discovery
…very' into fix/inherited-test-methods-discovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…methods-discovery
|
Hi Jinbo Wang (@testforstephen) Changyong Gong (@chagong) wenyt (@wenytang-ms), could a code owner please review this PR when you have a chance? I’d appreciate your feedback. Thank you! |
| methodItem.setProjectName(childProjectName); | ||
| methodItem.setId(childProjectName + "@" + childFullName); | ||
| methodItem.setExecutionClassName(childType.getFullyQualifiedName()); | ||
| } |
There was a problem hiding this comment.
Problem / Question
rebaseMethodItemToChild updates fullName, projectName, id, and executionClassName to point to childType, but leaves uri and range pointing to the parent declaration:
- Parent in external JAR (
parentType.getResource() == null):methodItem.getUri()remainsnull. On the TypeScript side (src/controller/testController.ts:278),runHandlerresolves the workspace folder viaworkspace.getWorkspaceFolder(itemsPerProject[0].uri!), which throws when a user runs an inherited binary method directly from the Test Explorer UI. - Parent in a different workspace project (
parent-projectvschild-project): Keeping the parent'suricausesworkspace.getWorkspaceFolder(item.uri)to resolve the parent's workspace folder instead of the child's. - Parent in workspace source: Keeping the parent's
uriandrangeattaches every child class's inheritedTestItemto the parent file's editor Gutter.
Solution
In rebaseMethodItemToChild, set uri to childType's resource URI and clear range to null:
private static void rebaseMethodItemToChild(JavaTestItem methodItem, IType childType)
throws JavaModelException {
final String fullName = methodItem.getFullName();
final int separator = fullName != null ? fullName.indexOf('#') : -1;
if (separator < 0) {
return;
}
final String childFullName = childType.getFullyQualifiedName() + fullName.substring(separator);
methodItem.setFullName(childFullName);
final String childProjectName = childType.getJavaProject().getProject().getName();
methodItem.setProjectName(childProjectName);
methodItem.setId(childProjectName + "@" + childFullName);
methodItem.setExecutionClassName(childType.getFullyQualifiedName());
if (childType.getResource() != null) {
methodItem.setUri(JDTUtils.getFileURI(childType.getResource()));
}
methodItem.setRange(null);
}There was a problem hiding this comment.
Thanks for catching the stale parent location. Inherited items now use the child test class resource URI when available and clear the parent method range; the parent JDT method handle remains intact for source navigation. Regression assertions cover the child URI and null range for source and binary parent inheritance.
| private static void addInheritedTestMethods(ITypeBinding classBinding, IType childType, | ||
| List<TestFrameworkSearcher> searchers, List<JavaTestItem> accumulator) throws JavaModelException { | ||
| if (classBinding == null || childType == null || searchers.isEmpty()) { | ||
| return; | ||
| } | ||
|
|
||
| final List<IMethodBinding> moreSpecificMethods = new LinkedList<>(); | ||
| for (final IMethodBinding declared : classBinding.getDeclaredMethods()) { | ||
| moreSpecificMethods.add(declared); | ||
| } | ||
|
|
||
| final Set<String> inheritedTestSignatures = new HashSet<>(); | ||
| ITypeBinding superClass = classBinding.getSuperclass(); | ||
| while (superClass != null && !"java.lang.Object".equals(superClass.getBinaryName())) { | ||
| for (final IMethodBinding methodBinding : superClass.getDeclaredMethods()) { | ||
| if (!isInheritedBy(methodBinding, classBinding)) { | ||
| continue; | ||
| } | ||
| final String key = getMethodSignatureKey(methodBinding); | ||
| if (inheritedTestSignatures.contains(key)) { | ||
| continue; | ||
| } | ||
| IMethodBinding overridingMethod = null; | ||
| for (final IMethodBinding moreSpecificMethod : moreSpecificMethods) { | ||
| if (moreSpecificMethod.overrides(methodBinding)) { | ||
| overridingMethod = moreSpecificMethod; | ||
| break; | ||
| } | ||
| } | ||
| for (final TestFrameworkSearcher searcher : searchers) { | ||
| if (!searcher.isTestMethod(methodBinding)) { | ||
| continue; | ||
| } | ||
| if (overridingMethod != null && | ||
| (searcher.getTestKind() != TestKind.JUnit || searcher.isTestMethod(overridingMethod))) { | ||
| break; | ||
| } | ||
| final IJavaElement element = methodBinding.getJavaElement(); | ||
| if (element == null) { | ||
| break; | ||
| } | ||
| final JavaTestItem methodItem = new JavaTestItemBuilder().setJavaElement(element) | ||
| .setLevel(TestLevel.METHOD) | ||
| .setKind(searcher.getTestKind()) | ||
| .build(); | ||
| rebaseMethodItemToChild(methodItem, childType); | ||
| accumulator.add(methodItem); | ||
| inheritedTestSignatures.add(key); | ||
| break; | ||
| } | ||
| } | ||
| Collections.addAll(moreSpecificMethods, superClass.getDeclaredMethods()); | ||
| superClass = superClass.getSuperclass(); | ||
| } | ||
| } | ||
|
|
||
| private static boolean isInheritedBy(IMethodBinding methodBinding, ITypeBinding childBinding) { | ||
| final int modifiers = methodBinding.getModifiers(); | ||
| if (Modifier.isPrivate(modifiers)) { | ||
| return false; | ||
| } | ||
| if (Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers)) { | ||
| return true; | ||
| } | ||
| return methodBinding.getDeclaringClass().getPackage() != null && | ||
| childBinding.getPackage() != null && | ||
| methodBinding.getDeclaringClass().getPackage().getName().equals(childBinding.getPackage().getName()); | ||
| } | ||
|
|
||
| private static String getMethodSignatureKey(IMethodBinding methodBinding) { | ||
| final StringBuilder key = new StringBuilder(methodBinding.getName()).append('('); | ||
| for (final ITypeBinding param : methodBinding.getParameterTypes()) { | ||
| final ITypeBinding erasure = param.getErasure() != null ? param.getErasure() : param; | ||
| key.append(erasure.getQualifiedName()).append(','); | ||
| } | ||
| return key.append(')').toString(); | ||
| } |
There was a problem hiding this comment.
Problem / Question
There are 3 issues in addInheritedTestMethods, isInheritedBy, and getMethodSignatureKey:
isInheritedBy()wrongly rejects cross-package package-private test methods in JUnit 5/6:
While Java does not allow overriding a package-private method across packages, JUnit Jupiter (JUnit 5/6) explicitly discovers and executes package-private@Testmethods declared in a superclass from a different package when running the subclass (via reflection). Onlyprivatemethods (Modifier.isPrivate(methodBinding.getModifiers())) should be skipped here; JLS override visibility rules are already handled byIMethodBinding.overrides().getMethodSignatureKey()andinheritedTestSignaturesare redundant:
Now thatmoreSpecificMethodscollects all methods from subclasses up the hierarchy and usesmoreSpecificMethod.overrides(methodBinding), manual string key construction viagetMethodSignatureKey()andinheritedTestSignaturesis unnecessary duplication.moreSpecificMethodsloop breaks on the first overriding method (break;):
In a 3-level JUnit 4 hierarchy (GrandParent(@Test) ->Parent(overrides without@Test) ->Child(overrides with@Test)), whensuperClassreachesGrandParent,moreSpecificMethodscontains bothChild#testandParent#test. Stopping at the first match (Child#test, which has@Test) causesGrandParent#testto be skipped even thoughParentremoved@Testand cancelled the test in JUnit 4.
Solution
Remove isInheritedBy(), getMethodSignatureKey(), and inheritedTestSignatures (and update testPackagePrivateTestMethodIsNotInheritedAcrossPackages in TestSearchUtilsTest.java accordingly), and simplify addInheritedTestMethods to:
private static void addInheritedTestMethods(ITypeBinding classBinding, IType childType,
List<TestFrameworkSearcher> searchers, List<JavaTestItem> accumulator) throws JavaModelException {
if (classBinding == null || childType == null || searchers.isEmpty()) {
return;
}
final List<IMethodBinding> moreSpecificMethods = new LinkedList<>();
Collections.addAll(moreSpecificMethods, classBinding.getDeclaredMethods());
ITypeBinding superClass = classBinding.getSuperclass();
while (superClass != null && !"java.lang.Object".equals(superClass.getBinaryName())) {
for (final IMethodBinding methodBinding : superClass.getDeclaredMethods()) {
if (Modifier.isPrivate(methodBinding.getModifiers())) {
continue;
}
final IJavaElement element = methodBinding.getJavaElement();
if (element == null) {
continue;
}
for (final TestFrameworkSearcher searcher : searchers) {
if (!searcher.isTestMethod(methodBinding)) {
continue;
}
final boolean overriddenBySubclass = moreSpecificMethods.stream()
.filter(m -> m.overrides(methodBinding))
.anyMatch(m -> searcher.getTestKind() != TestKind.JUnit || searcher.isTestMethod(m));
if (overriddenBySubclass) {
break;
}
final JavaTestItem methodItem = new JavaTestItemBuilder().setJavaElement(element)
.setLevel(TestLevel.METHOD)
.setKind(searcher.getTestKind())
.build();
rebaseMethodItemToChild(methodItem, childType);
accumulator.add(methodItem);
break;
}
}
Collections.addAll(moreSpecificMethods, superClass.getDeclaredMethods());
superClass = superClass.getSuperclass();
}
}There was a problem hiding this comment.
Thanks for the detailed review and suggested fix. The implementation follows that approach: it skips private methods and uses JDT override relationships instead of signature-key deduplication. I added the exact three-level JUnit 4 regression you described (annotated grandparent, unannotated middle override, annotated child override), plus an unbounded-generic regression that verifies the inherited launch selector uses java.lang.Object after erasure. The existing cross-package package-private JUnit 5 regression remains covered.
The Java reactor (mvnw clean verify -Declipse.p2.mirrors=false) and npm run build-plugin both pass. I also manually ran the inherited binary test from the child class, paused in the inherited parent method under Debug, verified this was the child test class, and continued to a passing 1/1 result.
| private String resolveMethodTestName(String handleId, String executionClassName) throws CoreException { | ||
| final IMethod method = (IMethod) JavaCore.create(handleId); | ||
| String testName = method.getElementName(); | ||
| if ((this.args.testKind == TestKind.JUnit5 || this.args.testKind == TestKind.JUnit6) && | ||
| method.getParameters().length > 0) { | ||
| final ICompilationUnit unit = method.getCompilationUnit(); | ||
| if (unit == null) { | ||
| throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, | ||
| "Cannot get compilation unit of method" + method.getElementName(), null)); //$NON-NLS-1$ | ||
| } | ||
| final CompilationUnit root = (CompilationUnit) TestSearchUtils.parseToAst(unit, | ||
| false /*fromCache*/, new NullProgressMonitor()); | ||
| final MethodDeclaration methodDeclaration = ASTNodeSearchUtil.getMethodDeclarationNode(method, root); | ||
| if (methodDeclaration == null) { | ||
| throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, | ||
| "Cannot get method declaration of method" + method.getElementName(), null)); //$NON-NLS-1$ | ||
| } | ||
| final ASTParser parser = ASTParser.newParser(AST.getJLSLatest()); | ||
| parser.setProject(method.getJavaProject()); | ||
| final IBinding[] bindings = parser.createBindings(new IJavaElement[] { method }, | ||
| new NullProgressMonitor()); | ||
| if (bindings.length != 1 || !(bindings[0] instanceof IMethodBinding)) { | ||
| throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, | ||
| "Cannot resolve binary method " + method.getElementName(), null)); //$NON-NLS-1$ | ||
| } | ||
| testName += formatParameterTypes((IMethodBinding) bindings[0]); | ||
| } else { | ||
| final CompilationUnit root = (CompilationUnit) TestSearchUtils.parseToAst(unit, | ||
| false /*fromCache*/, new NullProgressMonitor()); | ||
| final MethodDeclaration methodDeclaration = ASTNodeSearchUtil.getMethodDeclarationNode(method, root); | ||
| if (methodDeclaration == null) { | ||
| throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, | ||
| "Cannot get method declaration of method" + method.getElementName(), null)); //$NON-NLS-1$ | ||
| } | ||
|
|
||
| final List<String> parameters = new LinkedList<>(); | ||
| for (final Object obj : methodDeclaration.parameters()) { | ||
| if (obj instanceof SingleVariableDeclaration) { | ||
| final ITypeBinding paramTypeBinding = ((SingleVariableDeclaration) obj) | ||
| .getType().resolveBinding(); | ||
| if (paramTypeBinding == null) { | ||
| throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, | ||
| "Cannot set argument for method" + methodDeclaration.toString(), null)); | ||
| } else if (paramTypeBinding.isPrimitive()) { | ||
| parameters.add(paramTypeBinding.getQualifiedName()); | ||
| } else { | ||
| parameters.add(paramTypeBinding.getBinaryName()); | ||
| final List<String> parameters = new LinkedList<>(); | ||
| for (final Object obj : methodDeclaration.parameters()) { | ||
| if (obj instanceof SingleVariableDeclaration) { | ||
| final ITypeBinding paramTypeBinding = ((SingleVariableDeclaration) obj) | ||
| .getType().resolveBinding(); | ||
| if (paramTypeBinding == null) { | ||
| throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR, | ||
| "Cannot set argument for method" + methodDeclaration.toString(), null)); | ||
| } | ||
| parameters.add(formatParameterType(paramTypeBinding)); | ||
| } | ||
| } | ||
| if (!parameters.isEmpty()) { | ||
| testName += "(" + String.join(",", parameters) + ")"; | ||
| } | ||
| } | ||
| if (parameters.size() > 0) { | ||
| testName += "(" + String.join(",", parameters) + ")"; | ||
| } | ||
| } | ||
| return method.getDeclaringType().getFullyQualifiedName() + ':' + testName; | ||
| final String className = StringUtils.isNotBlank(executionClassName) ? executionClassName : | ||
| method.getDeclaringType().getFullyQualifiedName(); | ||
| return className + ':' + testName; | ||
| } | ||
|
|
||
| private String formatParameterTypes(IMethodBinding methodBinding) { | ||
| final List<String> parameters = new LinkedList<>(); | ||
| for (final ITypeBinding parameterType : methodBinding.getParameterTypes()) { | ||
| parameters.add(formatParameterType(parameterType)); | ||
| } | ||
| return parameters.isEmpty() ? "" : "(" + String.join(",", parameters) + ")"; | ||
| } | ||
|
|
||
| private String formatParameterType(ITypeBinding parameterType) { |
There was a problem hiding this comment.
Problem / Question
- Duplicate code paths (
if (unit == null)vselse) & unnecessary AST traversal:
ASTParser.createBindings(new IJavaElement[] { method }, new NullProgressMonitor())works for both sourceIMethods (unit != null) and binaryIMethods (unit == null) in JDT. Keeping theelsebranch that callsTestSearchUtils.parseToAst(unit, false, ...)+ASTNodeSearchUtil.getMethodDeclarationNode+ loops overSingleVariableDeclarationAST nodes is redundant and leaves two parallel paths doing the same job. - Missing generic type erasure in
formatParameterType:
When an inherited test method comes from a generic parent class (e.g.,abstract class BaseTest<T> { @Test void base(T value) {} }),parameterType.isTypeVariable()istrue. In JDT (TypeBinding.getBinaryName()), callingparameterType.getBinaryName()directly on a type variableTreturns JDT's internal synthetic key (e.g.,pkg.BaseTest$T) instead of the runtime erased JVM class name (java.lang.Object), causing JUnit Platform'sDiscoverySelectors.selectMethodto fail withClassNotFoundExceptionat runtime. CallingparameterType.getErasure()first resolvesTto its runtime erased type (java.lang.Objector upper bound).
Solution
Unify both source and binary IMethods to use parser.createBindings(new IJavaElement[] { method }, ...) and call parameterType.getErasure() in formatParameterType:
private String resolveMethodTestName(String handleId, String executionClassName) throws CoreException {
final IMethod method = (IMethod) JavaCore.create(handleId);
String testName = method.getElementName();
if ((this.args.testKind == TestKind.JUnit5 || this.args.testKind == TestKind.JUnit6) &&
method.getParameters().length > 0) {
final ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setProject(method.getJavaProject());
final IBinding[] bindings = parser.createBindings(new IJavaElement[] { method },
new NullProgressMonitor());
if (bindings.length != 1 || !(bindings[0] instanceof IMethodBinding)) {
throw new CoreException(new Status(IStatus.ERROR, JUnitPlugin.PLUGIN_ID, IStatus.ERROR,
"Cannot resolve method binding for " + method.getElementName(), null)); //$NON-NLS-1$
}
testName += formatParameterTypes((IMethodBinding) bindings[0]);
}
final String className = StringUtils.isNotBlank(executionClassName) ? executionClassName :
method.getDeclaringType().getFullyQualifiedName();
return className + ':' + testName;
}
private String formatParameterTypes(IMethodBinding methodBinding) {
final List<String> parameters = new LinkedList<>();
for (final ITypeBinding parameterType : methodBinding.getParameterTypes()) {
parameters.add(formatParameterType(parameterType));
}
return parameters.isEmpty() ? "" : "(" + String.join(",", parameters) + ")";
}
private String formatParameterType(ITypeBinding parameterType) {
final ITypeBinding erasedType = parameterType.getErasure();
return erasedType.isPrimitive() ? erasedType.getQualifiedName() : erasedType.getBinaryName();
}(You can also clean up the unused CompilationUnit, MethodDeclaration, SingleVariableDeclaration, and ASTNodeSearchUtil imports above.)
There was a problem hiding this comment.
Thanks for the suggested simplification. The launch resolver now uses ASTParser.createBindings() for both source and binary IMethods and formats the parameter types after erasure, while retaining the child execution class. Regression tests cover generic parameter erasure and a parameterized method inherited from a binary superclass; both the individual inherited method and the complete child class passed in Test Explorer.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks for the review, wenyt (@wenytang-ms). I have addressed the feedback and pushed the changes. Could you please re-review when you have a chance? |
wenyt (wenytang-ms)
left a comment
There was a problem hiding this comment.
Thanks for addressing the previous feedback and adding the test coverage! Left two minor nit comments below.
| /** | ||
| * Rewrite an inherited method item so its full name and id are anchored at the child class | ||
| * (e.g. {@code Child#test}) instead of the declaring parent class. The display label, source | ||
| * range, and JDT handler still point at the parent declaration. | ||
| */ |
There was a problem hiding this comment.
Nit: Please update this Javadoc to match the latest implementation — uri now points to the child class and range is cleared to null (only the display label and JDT handler still point at the parent declaration).
There was a problem hiding this comment.
Thank you for catching this. I have updated the Javadoc to reflect that the URI points to the child class and the range is cleared.
| @Test | ||
| public void testInheritedMethodLaunchesAgainstChildClass() throws Exception { | ||
| importProjects(Arrays.asList("inheritance-parent", "inheritance-child")); | ||
| final IProject childProject = WorkspaceHelper.getProject("inheritance-child"); | ||
| final IJavaProject childJavaProject = JavaCore.create(childProject); | ||
| final IType parentType = JavaCore.create(WorkspaceHelper.getProject("inheritance-parent")) | ||
| .findType("example.InheritanceBaseTest"); | ||
|
|
||
| final Map<String, Object> request = new LinkedHashMap<>(); | ||
| request.put("projectName", childProject.getName()); | ||
| request.put("testLevel", 6); | ||
| request.put("testKind", 0); | ||
| request.put("testNames", Arrays.asList(parentType.getMethod("base", new String[0]).getHandleIdentifier())); | ||
| request.put("executionClassNames", Arrays.asList("example.InheritanceChildTest")); | ||
|
|
||
| final Response<JUnitLaunchArguments> response = JUnitLaunchUtils.resolveLaunchArgument( | ||
| Arrays.asList(new Gson().toJson(request)), new NullProgressMonitor()); | ||
|
|
||
| assertEquals(0, response.getStatus()); | ||
| assertEquals(childJavaProject.getElementName(), response.getBody().projectName); | ||
| assertTrue(Arrays.asList(response.getBody().programArguments) | ||
| .contains("example.InheritanceChildTest:base")); | ||
| } |
There was a problem hiding this comment.
Nit: This test method uses 8-space indentation instead of 4-space indentation like the rest of the file.
There was a problem hiding this comment.
Thank you for pointing this out. I have updated the indentation to match the surrounding tests.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thank you for your review, wenyt (@wenytang-ms). I have addressed these comments and pushed the updates. Could you please review the changes again when you have a chance? |
Summary
When running a test class that extends a test base class, inherited
@Testmethods were not included in discovery. This could make the child-class run appear successful while silently skipping tests defined by its parent.This change discovers eligible test methods throughout the superclass hierarchy and lists inherited methods under the child test class.
Changes
Gradle source-set configuration and task behavior are unchanged.
Reproducer
https://github.com/sudovinay01/repro-java-issue
Validation
java-extension/mvnw.cmd clean verify -Declipse.p2.mirrors=falsepassed.AppMainSourceTestChildexecutes both the inherited and child-defined methods.images of issue