Skip to content

Commit ce7bdd6

Browse files
committed
feat/group-filter adds the ability to filter test by group and run group tests from command.
1 parent cdc47bf commit ce7bdd6

File tree

9 files changed

+181
-3
lines changed

9 files changed

+181
-3
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
{
6767
"command": "phpunit.rerun",
6868
"title": "PHPUnit: Repeat the last test run"
69+
},
70+
{
71+
"command": "phpunit.run-by-group",
72+
"title": "PHPUnit: Run tests by group"
6973
}
7074
],
7175
"keybindings": [

src/CommandHandler.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CancellationTokenSource, commands, TestItem, TestRunProfile, TestRunRequest, window } from 'vscode';
22
import { Handler } from './Handler';
3-
import { TestCollection } from './TestCollection';
3+
import { GroupRegistry, TestCollection } from './TestCollection';
44

55
export class CommandHandler {
66
constructor(private testCollection: TestCollection, private testRunProfile: TestRunProfile) {}
@@ -53,6 +53,28 @@ export class CommandHandler {
5353
});
5454
}
5555

56+
runByGroup(handler: Handler) {
57+
return commands.registerCommand('phpunit.run-by-group', async () => {
58+
const groups = GroupRegistry.getInstance().getAll();
59+
if (groups.length === 0) {
60+
window.showInformationMessage('No PHPUnit groups found. Add @group annotations or #[Group] attributes to your tests.');
61+
return;
62+
}
63+
64+
const selectedGroup = await window.showQuickPick(groups, {
65+
placeHolder: 'Select a PHPUnit group to run',
66+
title: 'Run Tests by Group',
67+
});
68+
69+
if (!selectedGroup || !handler) {
70+
return;
71+
}
72+
73+
const cancellation = new CancellationTokenSource().token;
74+
await handler.startGroupTestRun(selectedGroup, cancellation);
75+
});
76+
}
77+
5678
private async run(include: readonly TestItem[] | undefined) {
5779
const cancellation = new CancellationTokenSource().token;
5880

src/Handler.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@ export class Handler {
5151
this.previousRequest = request;
5252
}
5353

54+
async startGroupTestRun(group: string, cancellation?: CancellationToken) {
55+
const builder = new Builder(this.configuration, { cwd: this.phpUnitXML.root() });
56+
builder.setArguments(`--group ${group}`);
57+
58+
const request = new TestRunRequest();
59+
const testRun = this.ctrl.createTestRun(request);
60+
61+
const runner = new TestRunner();
62+
const queue = await this.discoverTests(this.gatherTestItems(this.ctrl.items), request);
63+
queue.forEach((testItem) => testRun.enqueued(testItem));
64+
65+
runner.observe(new TestResultObserver(queue, testRun));
66+
runner.observe(new OutputChannelObserver(this.outputChannel, this.configuration, this.printer, request));
67+
runner.observe(new MessageObserver(this.configuration));
68+
69+
runner.emit(TestRunnerEvent.start, undefined);
70+
71+
const process = runner.run(builder);
72+
cancellation?.onCancellationRequested(() => process.abort());
73+
74+
await process.run();
75+
runner.emit(TestRunnerEvent.done, undefined);
76+
testRun.end();
77+
}
78+
5479
private async runTestQueue(builder: Builder, testRun: TestRun, request: TestRunRequest, cancellation?: CancellationToken) {
5580
const queue = await this.discoverTests(request.include ?? this.gatherTestItems(this.ctrl.items), request);
5681
queue.forEach((testItem) => testRun.enqueued(testItem));

src/PHPUnit/TestParser/AnnotationParser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Declaration, Method } from 'php-parser';
22
import { Annotations } from '../types';
33

4-
const lookup = ['depends', 'dataProvider', 'testdox'];
4+
const lookup = ['depends', 'dataProvider', 'testdox', 'group'];
55

66
export class AttributeParser {
77
public parse(declaration: Declaration) {

src/PHPUnit/TestParser/PHPUnitParser.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,4 +489,82 @@ final class TestDoxTest extends TestCase {
489489
depth: 2,
490490
}));
491491
});
492+
493+
it('parse @group annotation', () => {
494+
const file = phpUnitProject('tests/GroupTest.php');
495+
const content = `<?php declare(strict_types=1);
496+
497+
use PHPUnit\\Framework\\TestCase;
498+
499+
final class GroupTest extends TestCase {
500+
/**
501+
* @group integration
502+
* @group slow
503+
*/
504+
public function test_with_groups() {
505+
$this->assertTrue(true);
506+
}
507+
}
508+
`;
509+
expect(givenTest(file, content, 'test_with_groups')).toEqual(expect.objectContaining({
510+
type: TestType.method,
511+
file,
512+
id: 'Group::With groups',
513+
classFQN: 'GroupTest',
514+
className: 'GroupTest',
515+
methodName: 'test_with_groups',
516+
annotations: { group: ['integration', 'slow'] },
517+
depth: 2,
518+
}));
519+
});
520+
521+
it('parse #[Group] attribute', () => {
522+
const file = phpUnitProject('tests/GroupAttributeTest.php');
523+
const content = `<?php declare(strict_types=1);
524+
525+
use PHPUnit\\Framework\\TestCase;
526+
use PHPUnit\\Framework\\Attributes\\Group;
527+
528+
final class GroupAttributeTest extends TestCase {
529+
#[Group('plaid')]
530+
#[Group('api')]
531+
public function test_with_group_attributes() {
532+
$this->assertTrue(true);
533+
}
534+
}
535+
`;
536+
expect(givenTest(file, content, 'test_with_group_attributes')).toEqual(expect.objectContaining({
537+
type: TestType.method,
538+
file,
539+
id: 'Group Attribute::With group attributes',
540+
classFQN: 'GroupAttributeTest',
541+
className: 'GroupAttributeTest',
542+
methodName: 'test_with_group_attributes',
543+
annotations: { group: ['plaid', 'api'] },
544+
depth: 2,
545+
}));
546+
});
547+
548+
it('parse single @group annotation', () => {
549+
const file = phpUnitProject('tests/SingleGroupTest.php');
550+
const content = `<?php declare(strict_types=1);
551+
552+
use PHPUnit\\Framework\\TestCase;
553+
554+
final class SingleGroupTest extends TestCase {
555+
/**
556+
* @group unit
557+
*/
558+
public function test_unit() {
559+
$this->assertTrue(true);
560+
}
561+
}
562+
`;
563+
expect(givenTest(file, content, 'test_unit')).toEqual(expect.objectContaining({
564+
type: TestType.method,
565+
file,
566+
methodName: 'test_unit',
567+
annotations: { group: ['unit'] },
568+
}));
569+
});
492570
});

src/PHPUnit/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type Annotations = {
1515
depends?: string[];
1616
dataProvider?: string[];
1717
testdox?: string[];
18+
group?: string[];
1819
};
1920
export type TestDefinition = {
2021
type: TestType;

src/TestCollection/TestCase.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export class TestCase {
99
return this.testDefinition.type;
1010
}
1111

12+
get groups(): string[] {
13+
return (this.testDefinition.annotations?.group as string[]) ?? [];
14+
}
15+
1216
update(builder: Builder, index: number) {
1317
return builder.clone()
1418
.setXdebug(builder.getXdebug()?.clone().setIndex(index))

src/TestCollection/TestHierarchyBuilder.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
1-
import { Position, Range, TestController, TestItem, Uri } from 'vscode';
1+
import { Position, Range, TestController, TestItem, TestTag, Uri } from 'vscode';
22
import { CustomWeakMap, TestDefinition, TestParser, TestType, TransformerFactory } from '../PHPUnit';
33
import { TestCase } from './TestCase';
44

5+
export class GroupRegistry {
6+
private static instance: GroupRegistry;
7+
private groups = new Set<string>();
8+
9+
static getInstance(): GroupRegistry {
10+
if (!GroupRegistry.instance) {
11+
GroupRegistry.instance = new GroupRegistry();
12+
}
13+
return GroupRegistry.instance;
14+
}
15+
16+
add(group: string) {
17+
this.groups.add(group);
18+
}
19+
20+
addAll(groups: string[]) {
21+
groups.forEach(g => this.groups.add(g));
22+
}
23+
24+
getAll(): string[] {
25+
return Array.from(this.groups).sort();
26+
}
27+
28+
clear() {
29+
this.groups.clear();
30+
}
31+
}
32+
533
export class TestHierarchyBuilder {
634
private icons = {
735
[TestType.namespace]: '$(symbol-namespace)',
@@ -83,6 +111,15 @@ export class TestHierarchyBuilder {
83111
const parent = this.ancestors[this.ancestors.length - 1];
84112
parent.children.push(testItem);
85113

114+
// Inherit group tags from parent class to methods for proper filter inheritance
115+
if (testDefinition.type === TestType.method && parent.type === TestType.class) {
116+
const parentTags = parent.item.tags.filter(t => t.id.startsWith('group:'));
117+
if (parentTags.length > 0) {
118+
const ownTags = testItem.tags;
119+
testItem.tags = [...ownTags, ...parentTags.filter(pt => !ownTags.some(ot => ot.id === pt.id))];
120+
}
121+
}
122+
86123
if (testDefinition.type !== TestType.method) {
87124
this.ancestors.push({ item: testItem, type: testDefinition.type, children: [] });
88125
}
@@ -96,6 +133,12 @@ export class TestHierarchyBuilder {
96133
testItem.sortText = sortText;
97134
testItem.range = this.createRange(testDefinition);
98135

136+
const groups = (testDefinition.annotations?.group as string[]) ?? [];
137+
if (groups.length > 0) {
138+
GroupRegistry.getInstance().addAll(groups);
139+
testItem.tags = groups.map(g => new TestTag(`group:${g}`));
140+
}
141+
99142
return testItem;
100143
}
101144

src/extension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export async function activate(context: ExtensionContext) {
113113
context.subscriptions.push(commandHandler.runFile());
114114
context.subscriptions.push(commandHandler.runTestAtCursor());
115115
context.subscriptions.push(commandHandler.rerun(handler));
116+
context.subscriptions.push(commandHandler.runByGroup(handler));
116117
}
117118

118119
async function getWorkspaceTestPatterns() {

0 commit comments

Comments
 (0)