diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index 869fa694..3c010a6f 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -304,6 +304,8 @@ const window = {
};
}),
showErrorMessage: vi.fn(),
+ showInformationMessage: vi.fn(),
+ showQuickPick: vi.fn(),
};
const commands = (function () {
diff --git a/package.json b/package.json
index 5916919c..64e14531 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,10 @@
"command": "phpunit.run-test-at-cursor",
"title": "PHPUnit: Run the test at the current cursor position"
},
+ {
+ "command": "phpunit.run-by-group",
+ "title": "PHPUnit: Run tests by group"
+ },
{
"command": "phpunit.rerun",
"title": "PHPUnit: Repeat the last test run"
diff --git a/src/Commands/TestCommandRegistry.ts b/src/Commands/TestCommandRegistry.ts
index 9d1f645d..f5dd588e 100644
--- a/src/Commands/TestCommandRegistry.ts
+++ b/src/Commands/TestCommandRegistry.ts
@@ -68,6 +68,44 @@ export class TestCommandRegistry {
});
}
+ runByGroup() {
+ return commands.registerCommand('phpunit.run-by-group', async () => {
+ let groups = this.testCollection.findGroups();
+ if (groups.length === 0) {
+ await this.testFileDiscovery.reloadAll();
+ groups = this.testCollection.findGroups();
+ }
+ if (groups.length === 0) {
+ window.showInformationMessage(
+ 'No PHPUnit groups found. Add @group annotations or #[Group] attributes to your tests.',
+ );
+ return;
+ }
+
+ const selectedGroup = await window.showQuickPick(groups, {
+ placeHolder: 'Select a PHPUnit group to run',
+ title: 'Run Tests by Group',
+ });
+ if (!selectedGroup) {
+ return;
+ }
+
+ const tests = this.testCollection.findTestsByGroup(selectedGroup);
+ if (tests.length === 0) {
+ window.showInformationMessage(`No tests found for group "${selectedGroup}".`);
+ return;
+ }
+
+ const cancellation = new CancellationTokenSource().token;
+ await this.handler.startGroupTestRun(
+ selectedGroup,
+ tests,
+ cancellation,
+ this.testRunProfile,
+ );
+ });
+ }
+
rerun() {
return commands.registerCommand('phpunit.rerun', () => {
return this.run(
diff --git a/src/TestCollection/TestCollection.test.ts b/src/TestCollection/TestCollection.test.ts
index 8a526338..4dfb9f91 100644
--- a/src/TestCollection/TestCollection.test.ts
+++ b/src/TestCollection/TestCollection.test.ts
@@ -100,6 +100,30 @@ describe('Extension TestCollection', () => {
]);
});
+ it('find groups and tests by group', async () => {
+ const collection = givenTestCollection(`
+
+
+ tests
+
+ `);
+
+ await collection.add(URI.file(phpUnitProject('tests/AssertionsTest.php')));
+ await collection.add(URI.file(phpUnitProject('tests/AttributeTest.php')));
+
+ expect(collection.findGroups()).toEqual(['integration']);
+ expect(collection.findTestsByGroup('integration')).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'Assertions (Tests\\Assertions)::Passed',
+ }),
+ expect.objectContaining({
+ id: 'Attribute (Tests\\Attribute)::Hi',
+ }),
+ ]),
+ );
+ });
+
it('with testsuites', async () => {
const collection = givenTestCollection(`
diff --git a/src/TestCollection/TestCollection.ts b/src/TestCollection/TestCollection.ts
index 0a2b91ee..50711fb7 100644
--- a/src/TestCollection/TestCollection.ts
+++ b/src/TestCollection/TestCollection.ts
@@ -64,6 +64,37 @@ export class TestCollection extends BaseTestCollection {
return tests.length > 0 ? tests : undefined;
}
+ findGroups(): string[] {
+ const groups = new Set();
+ for (const [, testData] of this.getTestData()) {
+ testData.forEach((_, testItem: TestItem) => {
+ (testItem.tags ?? [])
+ .filter((tag) => tag.id.startsWith('group:'))
+ .forEach((tag) => groups.add(tag.id.replace(/^group:/, '')));
+ });
+ }
+
+ return [...groups].sort();
+ }
+
+ findTestsByGroup(group: string): TestItem[] {
+ const groupTagId = `group:${group}`;
+ const tests: TestItem[] = [];
+ for (const [, testData] of this.getTestData()) {
+ for (const [testItem, testCase] of testData) {
+ if (testCase.type !== TestType.method) {
+ continue;
+ }
+
+ if ((testItem.tags ?? []).some((tag) => tag.id === groupTagId)) {
+ tests.push(testItem);
+ }
+ }
+ }
+
+ return tests;
+ }
+
reset() {
for (const [, testData] of this.getTestData()) {
for (const [testItem] of testData) {
diff --git a/src/TestExecution/TestRunHandler.ts b/src/TestExecution/TestRunHandler.ts
index 7a34315c..b418908b 100644
--- a/src/TestExecution/TestRunHandler.ts
+++ b/src/TestExecution/TestRunHandler.ts
@@ -3,8 +3,10 @@ import {
type CancellationToken,
debug,
type TestController,
+ type TestItem,
+ type TestRunProfile,
type TestRun,
- type TestRunRequest,
+ TestRunRequest,
workspace,
} from 'vscode';
import { Configuration } from '../Configuration';
@@ -52,6 +54,23 @@ export class TestRunHandler {
this.previousRequest = request;
}
+ async startGroupTestRun(
+ group: string,
+ include: readonly TestItem[],
+ cancellation?: CancellationToken,
+ profile?: TestRunProfile,
+ ) {
+ const request = new TestRunRequest(include, undefined, profile);
+ const builder = await this.createProcessBuilder(request);
+ const xdebug = builder.getXdebug()!;
+ builder.setArguments(`--group=${group}`);
+
+ await this.manageDebugSession(xdebug, async () => {
+ const testRun = this.ctrl.createTestRun(request);
+ await this.runTestQueue(builder, testRun, request, cancellation, true);
+ });
+ }
+
private async createProcessBuilder(request: TestRunRequest): Promise {
const builder = new ProcessBuilder(this.configuration, { cwd: this.phpUnitXML.root() });
const xdebug = new Xdebug(this.configuration);
@@ -80,6 +99,7 @@ export class TestRunHandler {
testRun: TestRun,
request: TestRunRequest,
cancellation?: CancellationToken,
+ forceSingleProcess = false,
) {
const queue = await this.testQueueBuilder.build(
request.include ?? this.testQueueBuilder.collectItems(this.ctrl.items),
@@ -90,7 +110,7 @@ export class TestRunHandler {
const runner = this.testRunnerBuilder.build(queue, testRun, request);
runner.emit(TestRunnerEvent.start, undefined);
- const processes = this.createProcesses(runner, builder, request);
+ const processes = this.createProcesses(runner, builder, request, forceSingleProcess);
cancellation?.onCancellationRequested(() =>
processes.forEach((process) => process.abort()),
);
@@ -101,8 +121,13 @@ export class TestRunHandler {
runner.emit(TestRunnerEvent.done, undefined);
}
- private createProcesses(runner: TestRunner, builder: ProcessBuilder, request: TestRunRequest) {
- if (!request.include) {
+ private createProcesses(
+ runner: TestRunner,
+ builder: ProcessBuilder,
+ request: TestRunRequest,
+ forceSingleProcess = false,
+ ) {
+ if (!request.include || forceSingleProcess) {
return [runner.run(builder)];
}
diff --git a/src/extension.test.ts b/src/extension.test.ts
index abfe664b..bc8fe2fa 100644
--- a/src/extension.test.ts
+++ b/src/extension.test.ts
@@ -11,6 +11,7 @@ import {
type TestController,
type TestItem,
type TestItemCollection,
+ TestRunRequest,
TestRunProfileKind,
type TextDocument,
tests,
@@ -246,11 +247,12 @@ describe('Extension Test', () => {
'phpunit.run-all',
'phpunit.run-file',
'phpunit.run-test-at-cursor',
+ 'phpunit.run-by-group',
'phpunit.rerun',
]) {
expect(commands.registerCommand).toHaveBeenCalledWith(cmd, expect.any(Function));
}
- expect(context.subscriptions.push).toHaveBeenCalledTimes(7);
+ expect(context.subscriptions.push).toHaveBeenCalledTimes(8);
});
it('should only update configuration when phpunit settings change', async () => {
@@ -368,6 +370,53 @@ describe('Extension Test', () => {
await configuration.update('args', []);
});
+ it('should run tests by selected group', async () => {
+ await activate(context);
+ (window.showQuickPick as Mock).mockResolvedValue('integration');
+
+ await commands.executeCommand('phpunit.run-by-group');
+ const testRunRequestCalls = (TestRunRequest as unknown as Mock).mock.calls;
+ const [, , profile] = testRunRequestCalls[testRunRequestCalls.length - 1];
+
+ expect(window.showQuickPick).toHaveBeenCalledWith(
+ expect.arrayContaining(['integration']),
+ expect.objectContaining({
+ placeHolder: 'Select a PHPUnit group to run',
+ title: 'Run Tests by Group',
+ }),
+ );
+ expectSpawnCalled([
+ binary,
+ '--group=integration',
+ '--colors=never',
+ '--teamcity',
+ ]);
+ expect(profile).toBe(getTestRunProfile(getTestController()));
+ });
+
+ it('should discover tests before running selected group when tests are not preloaded', async () => {
+ setTextDocuments([]);
+ await activate(context);
+ (window.showQuickPick as Mock).mockResolvedValue('integration');
+
+ await commands.executeCommand('phpunit.run-by-group');
+
+ expect(workspace.findFiles).toHaveBeenCalled();
+ expect(window.showQuickPick).toHaveBeenCalledWith(
+ expect.arrayContaining(['integration']),
+ expect.objectContaining({
+ placeHolder: 'Select a PHPUnit group to run',
+ title: 'Run Tests by Group',
+ }),
+ );
+ expectSpawnCalled([
+ binary,
+ '--group=integration',
+ '--colors=never',
+ '--teamcity',
+ ]);
+ });
+
it('should run class with group', async () => {
await activateAndRun({ include: 'Attribute (Tests\\Attribute)' });
diff --git a/src/extension.ts b/src/extension.ts
index d49aa5d0..7ac7ad9a 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -108,6 +108,7 @@ function registerCommands(
context.subscriptions.push(testCommandRegistry.runAll());
context.subscriptions.push(testCommandRegistry.runFile());
context.subscriptions.push(testCommandRegistry.runTestAtCursor());
+ context.subscriptions.push(testCommandRegistry.runByGroup());
context.subscriptions.push(testCommandRegistry.rerun());
}