Skip to content

Commit e39604f

Browse files
committed
feat: support inherited test methods from abstract base classes
Add ClassRegistry to track class inheritance across files, enabling Test Explorer to discover test methods inherited from abstract parent classes (fixes #277, #164). - Add ClassRegistry with extendsTestCase/resolveInheritedMethods - Extract extends/use-statement resolution in PhpAstNodeWrapper - PHPUnitParser registers all classes and merges inherited methods - TestCollection re-parses child classes when parent file changes - ClassRegistry injected via constructor for proper DI
1 parent d6328df commit e39604f

File tree

12 files changed

+788
-24
lines changed

12 files changed

+788
-24
lines changed

src/PHPUnit/TestCollection/TestCollection.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { extname, join } from 'node:path';
22
import { Minimatch } from 'minimatch';
33
import { URI } from 'vscode-uri';
44
import { type PHPUnitXML, type TestDefinition, TestParser, type TestSuite } from '../index';
5+
import { ClassRegistry } from '../TestParser/ClassRegistry';
56
import { TestDefinitionCollector } from './TestDefinitionCollector';
67

78
export interface File<T> {
@@ -14,6 +15,7 @@ export class TestCollection {
1415
private suites = new Map<string, Map<string, TestDefinition[]>>();
1516
private matcherCache = new Map<string, Map<string, Minimatch>>();
1617
private fileIndex = new Map<string, string>();
18+
private classRegistry = new ClassRegistry();
1719

1820
constructor(private phpUnitXML: PHPUnitXML) {}
1921

@@ -53,14 +55,41 @@ export class TestCollection {
5355
const testDefinitions = await this.parseTests(uri, testsuite);
5456
if (testDefinitions.length === 0) {
5557
this.delete(uri);
56-
return this;
58+
} else {
59+
files.get(testsuite)?.set(uri.toString(), testDefinitions);
60+
this.fileIndex.set(uri.toString(), testsuite);
5761
}
58-
files.get(testsuite)?.set(uri.toString(), testDefinitions);
59-
this.fileIndex.set(uri.toString(), testsuite);
62+
63+
// Re-parse child classes that depend on classes defined in this file
64+
await this.reparseChildClasses(uri);
6065

6166
return this;
6267
}
6368

69+
private async reparseChildClasses(uri: URI) {
70+
// Find all classes registered from this file in the registry
71+
const classesInFile = this.classRegistry.getClassesByUri(uri.fsPath);
72+
73+
for (const classInfo of classesInFile) {
74+
const children = this.classRegistry.getChildClasses(classInfo.classFQN);
75+
for (const child of children) {
76+
if (child.uri === uri.fsPath) {
77+
continue;
78+
}
79+
const childUri = URI.file(child.uri);
80+
const childTestsuite = this.parseTestsuite(childUri);
81+
if (!childTestsuite) {
82+
continue;
83+
}
84+
const childTests = await this.parseTests(childUri, childTestsuite);
85+
if (childTests.length > 0) {
86+
this.items().get(childTestsuite)?.set(childUri.toString(), childTests);
87+
this.fileIndex.set(childUri.toString(), childTestsuite);
88+
}
89+
}
90+
}
91+
}
92+
6493
get(uri: URI) {
6594
return this.findFile(uri)?.tests;
6695
}
@@ -82,6 +111,7 @@ export class TestCollection {
82111
this.suites.clear();
83112
this.matcherCache.clear();
84113
this.fileIndex.clear();
114+
this.classRegistry.clear();
85115

86116
return this;
87117
}
@@ -109,7 +139,7 @@ export class TestCollection {
109139
}
110140

111141
protected createTestParser() {
112-
const testParser = new TestParser(this.phpUnitXML);
142+
const testParser = new TestParser(this.phpUnitXML, this.classRegistry);
113143
const testDefinitionBuilder = new TestDefinitionCollector(testParser);
114144

115145
return { testParser, testDefinitionBuilder };
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { TestType } from '../types';
3+
import { ClassRegistry } from './ClassRegistry';
4+
5+
describe('ClassRegistry', () => {
6+
it('should register and retrieve class info', () => {
7+
const registry = new ClassRegistry();
8+
registry.register({
9+
uri: '/test.php',
10+
classFQN: 'App\\MyClass',
11+
parentFQN: 'App\\BaseClass',
12+
isAbstract: false,
13+
methods: [],
14+
});
15+
16+
const info = registry.get('App\\MyClass');
17+
expect(info).toBeDefined();
18+
expect(info?.parentFQN).toBe('App\\BaseClass');
19+
});
20+
21+
it('should detect TestCase inheritance', () => {
22+
const registry = new ClassRegistry();
23+
registry.register({
24+
uri: '/abstract.php',
25+
classFQN: 'App\\AbstractTest',
26+
parentFQN: 'PHPUnit\\Framework\\TestCase',
27+
isAbstract: true,
28+
methods: [],
29+
});
30+
registry.register({
31+
uri: '/concrete.php',
32+
classFQN: 'App\\ConcreteTest',
33+
parentFQN: 'App\\AbstractTest',
34+
isAbstract: false,
35+
methods: [],
36+
});
37+
38+
expect(registry.extendsTestCase('App\\ConcreteTest')).toBe(true);
39+
expect(registry.extendsTestCase('App\\AbstractTest')).toBe(true);
40+
expect(registry.extendsTestCase('App\\UnknownClass')).toBe(false);
41+
});
42+
43+
it('should detect circular inheritance', () => {
44+
const registry = new ClassRegistry();
45+
registry.register({
46+
uri: '/a.php',
47+
classFQN: 'A',
48+
parentFQN: 'B',
49+
isAbstract: false,
50+
methods: [],
51+
});
52+
registry.register({
53+
uri: '/b.php',
54+
classFQN: 'B',
55+
parentFQN: 'A',
56+
isAbstract: false,
57+
methods: [],
58+
});
59+
60+
expect(registry.extendsTestCase('A')).toBe(false);
61+
});
62+
63+
it('should resolve inherited methods', () => {
64+
const registry = new ClassRegistry();
65+
const parentMethod = {
66+
type: TestType.method,
67+
id: 'test',
68+
label: 'test',
69+
methodName: 'test_parent',
70+
depth: 2,
71+
};
72+
const childMethod = {
73+
type: TestType.method,
74+
id: 'test',
75+
label: 'test',
76+
methodName: 'test_child',
77+
depth: 2,
78+
};
79+
80+
registry.register({
81+
uri: '/parent.php',
82+
classFQN: 'Parent',
83+
parentFQN: 'PHPUnit\\Framework\\TestCase',
84+
isAbstract: true,
85+
methods: [parentMethod],
86+
});
87+
registry.register({
88+
uri: '/child.php',
89+
classFQN: 'Child',
90+
parentFQN: 'Parent',
91+
isAbstract: false,
92+
methods: [childMethod],
93+
});
94+
95+
const methods = registry.resolveInheritedMethods('Child');
96+
expect(methods).toHaveLength(2);
97+
expect(methods.map((m) => m.methodName)).toContain('test_child');
98+
expect(methods.map((m) => m.methodName)).toContain('test_parent');
99+
});
100+
101+
it('child override should take precedence in resolveInheritedMethods', () => {
102+
const registry = new ClassRegistry();
103+
104+
registry.register({
105+
uri: '/parent.php',
106+
classFQN: 'Parent',
107+
parentFQN: 'PHPUnit\\Framework\\TestCase',
108+
isAbstract: true,
109+
methods: [
110+
{
111+
type: TestType.method,
112+
id: 'p',
113+
label: 'p',
114+
methodName: 'test_shared',
115+
file: '/parent.php',
116+
depth: 2,
117+
},
118+
],
119+
});
120+
registry.register({
121+
uri: '/child.php',
122+
classFQN: 'Child',
123+
parentFQN: 'Parent',
124+
isAbstract: false,
125+
methods: [
126+
{
127+
type: TestType.method,
128+
id: 'c',
129+
label: 'c',
130+
methodName: 'test_shared',
131+
file: '/child.php',
132+
depth: 2,
133+
},
134+
],
135+
});
136+
137+
const methods = registry.resolveInheritedMethods('Child');
138+
expect(methods).toHaveLength(1);
139+
expect(methods[0].file).toBe('/child.php');
140+
});
141+
142+
it('should find child classes', () => {
143+
const registry = new ClassRegistry();
144+
registry.register({
145+
uri: '/parent.php',
146+
classFQN: 'Parent',
147+
isAbstract: true,
148+
methods: [],
149+
});
150+
registry.register({
151+
uri: '/child1.php',
152+
classFQN: 'Child1',
153+
parentFQN: 'Parent',
154+
isAbstract: false,
155+
methods: [],
156+
});
157+
registry.register({
158+
uri: '/child2.php',
159+
classFQN: 'Child2',
160+
parentFQN: 'Parent',
161+
isAbstract: false,
162+
methods: [],
163+
});
164+
165+
const children = registry.getChildClasses('Parent');
166+
expect(children).toHaveLength(2);
167+
});
168+
169+
it('should delete by URI', () => {
170+
const registry = new ClassRegistry();
171+
registry.register({
172+
uri: '/test.php',
173+
classFQN: 'MyClass',
174+
isAbstract: false,
175+
methods: [],
176+
});
177+
178+
registry.deleteByUri('/test.php');
179+
expect(registry.get('MyClass')).toBeUndefined();
180+
});
181+
182+
it('should get classes by URI', () => {
183+
const registry = new ClassRegistry();
184+
registry.register({
185+
uri: '/test.php',
186+
classFQN: 'ClassA',
187+
isAbstract: false,
188+
methods: [],
189+
});
190+
registry.register({
191+
uri: '/test.php',
192+
classFQN: 'ClassB',
193+
isAbstract: false,
194+
methods: [],
195+
});
196+
registry.register({
197+
uri: '/other.php',
198+
classFQN: 'ClassC',
199+
isAbstract: false,
200+
methods: [],
201+
});
202+
203+
const classes = registry.getClassesByUri('/test.php');
204+
expect(classes).toHaveLength(2);
205+
});
206+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { TestDefinition } from '../types';
2+
3+
export interface ClassInfo {
4+
uri: string;
5+
classFQN: string;
6+
parentFQN?: string;
7+
isAbstract: boolean;
8+
methods: TestDefinition[];
9+
}
10+
11+
export class ClassRegistry {
12+
private registry = new Map<string, ClassInfo>();
13+
14+
clear(): void {
15+
this.registry.clear();
16+
}
17+
18+
register(info: ClassInfo): void {
19+
this.registry.set(info.classFQN, info);
20+
}
21+
22+
get(classFQN: string): ClassInfo | undefined {
23+
return this.registry.get(classFQN);
24+
}
25+
26+
deleteByUri(uri: string): void {
27+
for (const [key, info] of this.registry) {
28+
if (info.uri === key || info.uri === uri) {
29+
this.registry.delete(key);
30+
}
31+
}
32+
}
33+
34+
extendsTestCase(classFQN: string): boolean {
35+
const visited = new Set<string>();
36+
let current: string | undefined = classFQN;
37+
38+
while (current) {
39+
if (current === 'PHPUnit\\Framework\\TestCase') {
40+
return true;
41+
}
42+
43+
if (visited.has(current)) {
44+
return false;
45+
}
46+
visited.add(current);
47+
48+
const info = this.registry.get(current);
49+
if (!info) {
50+
return false;
51+
}
52+
53+
current = info.parentFQN;
54+
}
55+
56+
return false;
57+
}
58+
59+
resolveInheritedMethods(classFQN: string): TestDefinition[] {
60+
const visited = new Set<string>();
61+
const methodMap = new Map<string, TestDefinition>();
62+
let current: string | undefined = classFQN;
63+
64+
while (current) {
65+
if (visited.has(current)) {
66+
break;
67+
}
68+
visited.add(current);
69+
70+
const info = this.registry.get(current);
71+
if (!info) {
72+
break;
73+
}
74+
75+
for (const method of info.methods) {
76+
if (method.methodName && !methodMap.has(method.methodName)) {
77+
methodMap.set(method.methodName, method);
78+
}
79+
}
80+
81+
current = info.parentFQN;
82+
}
83+
84+
return [...methodMap.values()];
85+
}
86+
87+
getClassesByUri(uri: string): ClassInfo[] {
88+
const result: ClassInfo[] = [];
89+
for (const info of this.registry.values()) {
90+
if (info.uri === uri) {
91+
result.push(info);
92+
}
93+
}
94+
return result;
95+
}
96+
97+
getChildClasses(classFQN: string): ClassInfo[] {
98+
const children: ClassInfo[] = [];
99+
for (const info of this.registry.values()) {
100+
if (info.parentFQN === classFQN) {
101+
children.push(info);
102+
}
103+
}
104+
return children;
105+
}
106+
}

0 commit comments

Comments
 (0)