diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 19ce3d1a4..b7c356678 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -323,8 +323,18 @@ private function complete(TestResult $testResultSum): int /** @var list $failedEvents */ $failedEvents = array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()); + // A worker's `hasTests()` only reflects the *last* file it executed: + // PHPUnit's Collector overwrites `numberOfTests` on every + // `executionStarted` event. When a worker's final file has all of + // its tests excluded by `--group`/`--exclude-group`, that worker + // reports `hasTests() === false` even though it ran plenty of + // tests. If every worker happens to end on such a file, the merged + // result claims the whole suite was empty and the run exits 1 + // (PHPUnit enables `failOnEmptyTestSuite` implicitly for explicit + // test selections) — despite a fully green summary. Treating a + // worker with executed tests as "has tests" restores the truth. $testResultSum = new TestResult( - (int) $testResultSum->hasTests() + (int) $testResult->hasTests(), + (int) ($testResultSum->hasTests() || $testResult->hasTests() || $testResult->numberOfTestsRun() > 0), $testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(), $testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(), array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()), diff --git a/tests/.tests/ParallelGroupFilteredLastFile/ATest.php b/tests/.tests/ParallelGroupFilteredLastFile/ATest.php new file mode 100644 index 000000000..38ece0d06 --- /dev/null +++ b/tests/.tests/ParallelGroupFilteredLastFile/ATest.php @@ -0,0 +1,5 @@ +toBeTrue(); +})->group('filtered-group'); diff --git a/tests/.tests/ParallelGroupFilteredLastFile/ZTest.php b/tests/.tests/ParallelGroupFilteredLastFile/ZTest.php new file mode 100644 index 000000000..259551f9d --- /dev/null +++ b/tests/.tests/ParallelGroupFilteredLastFile/ZTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index d497eef3c..38cfb69aa 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -47,3 +47,38 @@ ->toContain('Tests: 1 failed, 1 passed (1 assertions)') ->toContain('Parallel: 3 processes'); })->skipOnWindows(); + +test('parallel group-filtered run is not considered empty when workers end on fully filtered files', function () { + // Regression: a worker's TestResult only carries `numberOfTests` of its + // LAST executed file (PHPUnit's Collector overwrites it per execution). + // With `--processes=1` the single worker deterministically ends on + // ZTest.php, whose only test is excluded by the group filter — before the + // fix the merged result claimed "no tests" and exited 1 despite the green + // summary (PHPUnit enables failOnEmptyTestSuite implicitly for --group). + $process = new Process( + ['php', 'bin/pest', '--parallel', '--processes=1', '--group=filtered-group', 'tests/.tests/ParallelGroupFilteredLastFile'], + dirname(__DIR__, 2), + ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'], + ); + + $process->run(); + + expect(removeAnsiEscapeSequences($process->getOutput())) + ->toContain('Tests: 1 passed (1 assertions)'); + + expect($process->getExitCode())->toBe(0); +})->skipOnWindows(); + +test('parallel group-filtered run still fails when the selection matches no tests at all', function () { + // Companion guard: a genuinely empty selection must keep exiting non-zero + // (failOnEmptyTestSuite) — the fix must not mask real "0 tests" runs. + $process = new Process( + ['php', 'bin/pest', '--parallel', '--processes=3', '--group=group-that-does-not-exist', 'tests/.tests/ParallelGroupFilteredLastFile'], + dirname(__DIR__, 2), + ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'], + ); + + $process->run(); + + expect($process->getExitCode())->not->toBe(0); +})->skipOnWindows();