Skip to content

Commit 7aa3f73

Browse files
committed
[tests] Replace coverage-check dependency (#30)
1 parent e91f42b commit 7aa3f73

8 files changed

Lines changed: 651 additions & 28 deletions

File tree

.github/workflows/tests.yml

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ name: Run PHPUnit Tests
22

33
on:
44
workflow_call:
5+
inputs:
6+
min-coverage:
7+
description: Minimum line coverage percentage enforced by dev-tools tests.
8+
required: false
9+
type: number
10+
default: 80
511
workflow_dispatch:
12+
inputs:
13+
min-coverage:
14+
description: Minimum line coverage percentage enforced by dev-tools tests.
15+
required: false
16+
type: number
17+
default: 80
618
pull_request:
719
push:
820
branches: [ "main" ]
@@ -46,13 +58,16 @@ jobs:
4658
with:
4759
php_version: ${{ matrix.php-version }}
4860

61+
- name: Resolve minimum coverage
62+
id: minimum-coverage
63+
run: echo "value=${INPUT_MIN_COVERAGE:-80}" >> "$GITHUB_OUTPUT"
64+
env:
65+
INPUT_MIN_COVERAGE: ${{ inputs.min-coverage }}
66+
4967
- name: Run PHPUnit tests
5068
uses: php-actions/composer@v6
5169
with:
52-
php_version: ${{ matrix.php-version }}
53-
php_extensions: pcov pcntl
54-
command: 'dev-tools'
55-
args: 'tests -- --coverage=public/coverage'
56-
57-
- name: Ensure minimum code coverage
58-
run: php vendor/bin/coverage-check ./public/coverage/clover.xml 80
70+
php_version: ${{ matrix.php-version }}
71+
php_extensions: pcov pcntl
72+
command: 'dev-tools'
73+
args: 'tests -- --coverage=public/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }}'

composer.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
"dg/bypass-finals": "^1.9",
3131
"ergebnis/composer-normalize": "^2.50",
3232
"ergebnis/rector-rules": "^1.14",
33-
"esi/phpunit-coverage-check": "^3.0",
3433
"fakerphp/faker": "^1.24",
3534
"fast-forward/phpdoc-bootstrap-template": "^1.0",
3635
"friendsofphp/php-cs-fixer": "^3.94",
@@ -47,7 +46,7 @@
4746
"rector/rector": "^2.3",
4847
"saggre/phpdocumentor-markdown": "^1.0",
4948
"shipmonk/composer-dependency-analyser": "^1.8.4",
50-
"symfony/console": "^7.3",
49+
"symfony/console": "^7.4",
5150
"symfony/filesystem": "^7.4",
5251
"symfony/finder": "^7.4",
5352
"symfony/process": "^7.4",

src/Command/TestsCommand.php

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@
1818

1919
namespace FastForward\DevTools\Command;
2020

21+
use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader;
22+
use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface;
23+
use InvalidArgumentException;
24+
use RuntimeException;
2125
use Symfony\Component\Console\Input\InputArgument;
2226
use Symfony\Component\Console\Input\InputInterface;
2327
use Symfony\Component\Console\Input\InputOption;
2428
use Symfony\Component\Console\Output\OutputInterface;
29+
use Symfony\Component\Filesystem\Filesystem;
2530
use Symfony\Component\Process\Process;
2631

32+
use function is_numeric;
33+
2734
/**
2835
* Facilitates the execution of the PHPUnit testing framework.
2936
* This class MUST NOT be overridden and SHALL configure testing parameters dynamically.
@@ -35,6 +42,17 @@ final class TestsCommand extends AbstractCommand
3542
*/
3643
public const string CONFIG = 'phpunit.xml';
3744

45+
/**
46+
* @param Filesystem|null $filesystem the filesystem utility used for path resolution
47+
* @param CoverageSummaryLoaderInterface $coverageSummaryLoader the loader used for `coverage-php` summaries
48+
*/
49+
public function __construct(
50+
?Filesystem $filesystem = null,
51+
private readonly CoverageSummaryLoaderInterface $coverageSummaryLoader = new CoverageSummaryLoader(),
52+
) {
53+
parent::__construct($filesystem);
54+
}
55+
3856
/**
3957
* Configures the testing command input constraints.
4058
*
@@ -84,6 +102,11 @@ protected function configure(): void
84102
shortcut: 'f',
85103
mode: InputOption::VALUE_OPTIONAL,
86104
description: 'Filter which tests to run based on a pattern.',
105+
)
106+
->addOption(
107+
name: 'min-coverage',
108+
mode: InputOption::VALUE_REQUIRED,
109+
description: 'Minimum line coverage percentage required for a successful run.',
87110
);
88111
}
89112

@@ -102,6 +125,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
102125
{
103126
$output->writeln('<info>Running PHPUnit tests...</info>');
104127

128+
try {
129+
$minimumCoverage = $this->resolveMinimumCoverage($input);
130+
} catch (InvalidArgumentException $exception) {
131+
$output->writeln('<error>' . $exception->getMessage() . '</error>');
132+
133+
return self::FAILURE;
134+
}
135+
105136
$arguments = [
106137
$this->getAbsolutePath('vendor/bin/phpunit'),
107138
'--configuration=' . parent::getConfigFile(self::CONFIG),
@@ -116,29 +147,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int
116147
$arguments[] = '--cache-directory=' . $this->resolvePath($input, 'cache-dir');
117148
}
118149

119-
if ($input->getOption('coverage')) {
120-
$output->writeln(
121-
'<info>Generating code coverage reports on path: ' . $this->resolvePath($input, 'coverage') . '</info>'
122-
);
123-
124-
foreach ($this->getPsr4Namespaces() as $path) {
125-
$arguments[] = '--coverage-filter=' . $this->getAbsolutePath($path);
126-
}
127-
128-
$arguments[] = '--coverage-text';
129-
$arguments[] = '--coverage-html=' . $this->resolvePath($input, 'coverage');
130-
$arguments[] = '--testdox-html=' . $this->resolvePath($input, 'coverage') . '/testdox.html';
131-
$arguments[] = '--coverage-clover=' . $this->resolvePath($input, 'coverage') . '/clover.xml';
132-
$arguments[] = '--coverage-php=' . $this->resolvePath($input, 'coverage') . '/coverage.php';
133-
}
150+
$coverageReportPath = $this->configureCoverageArguments($input, $arguments, null !== $minimumCoverage);
134151

135152
if ($input->getOption('filter')) {
136153
$arguments[] = '--filter=' . $input->getOption('filter');
137154
}
138155

139156
$command = new Process([...$arguments, $input->getArgument('path')]);
140157

141-
return parent::runProcess($command, $output);
158+
$result = parent::runProcess($command, $output);
159+
160+
if (self::SUCCESS !== $result || null === $minimumCoverage || null === $coverageReportPath) {
161+
return $result;
162+
}
163+
164+
return $this->validateMinimumCoverage($coverageReportPath, $minimumCoverage, $output);
142165
}
143166

144167
/**
@@ -156,4 +179,109 @@ private function resolvePath(InputInterface $input, string $option): string
156179
{
157180
return $this->getAbsolutePath($input->getOption($option));
158181
}
182+
183+
/**
184+
* @param InputInterface $input the raw parameter definitions
185+
*
186+
* @return float|null the validated minimum coverage percentage, if configured
187+
*/
188+
private function resolveMinimumCoverage(InputInterface $input): ?float
189+
{
190+
$minimumCoverage = $input->getOption('min-coverage');
191+
192+
if (null === $minimumCoverage) {
193+
return null;
194+
}
195+
196+
if (! is_numeric($minimumCoverage)) {
197+
throw new InvalidArgumentException('The --min-coverage option MUST be a numeric percentage.');
198+
}
199+
200+
$minimumCoverage = (float) $minimumCoverage;
201+
202+
if (0.0 > $minimumCoverage || 100.0 < $minimumCoverage) {
203+
throw new InvalidArgumentException('The --min-coverage option MUST be between 0 and 100.');
204+
}
205+
206+
return $minimumCoverage;
207+
}
208+
209+
/**
210+
* @param InputInterface $input the raw parameter definitions
211+
* @param array<int, string> $arguments the mutable argument list for the PHPUnit process
212+
* @param bool $requiresCoverageReport indicates whether a `coverage-php` report is required
213+
*
214+
* @return string|null the absolute path to the generated `coverage-php` report
215+
*/
216+
private function configureCoverageArguments(
217+
InputInterface $input,
218+
array &$arguments,
219+
bool $requiresCoverageReport,
220+
): ?string {
221+
$coverageOption = $input->getOption('coverage');
222+
223+
if (null === $coverageOption && ! $requiresCoverageReport) {
224+
return null;
225+
}
226+
227+
$coveragePath = null !== $coverageOption
228+
? $this->resolvePath($input, 'coverage')
229+
: $this->resolvePath($input, 'cache-dir');
230+
231+
foreach ($this->getPsr4Namespaces() as $path) {
232+
$arguments[] = '--coverage-filter=' . $this->getAbsolutePath($path);
233+
}
234+
235+
if (null !== $coverageOption) {
236+
$arguments[] = '--coverage-text';
237+
$arguments[] = '--coverage-html=' . $coveragePath;
238+
$arguments[] = '--testdox-html=' . $coveragePath . '/testdox.html';
239+
$arguments[] = '--coverage-clover=' . $coveragePath . '/clover.xml';
240+
}
241+
242+
$coverageReportPath = $coveragePath . '/coverage.php';
243+
$arguments[] = '--coverage-php=' . $coverageReportPath;
244+
245+
return $coverageReportPath;
246+
}
247+
248+
/**
249+
* @param string $coverageReportPath the generated `coverage-php` report path
250+
* @param float $minimumCoverage the required line coverage percentage
251+
* @param OutputInterface $output the output interface to log validation results
252+
*
253+
* @return int the final status code after validating minimum coverage
254+
*/
255+
private function validateMinimumCoverage(
256+
string $coverageReportPath,
257+
float $minimumCoverage,
258+
OutputInterface $output,
259+
): int {
260+
try {
261+
$coverageSummary = $this->coverageSummaryLoader->load($coverageReportPath);
262+
} catch (RuntimeException $exception) {
263+
$output->writeln('<error>' . $exception->getMessage() . '</error>');
264+
265+
return self::FAILURE;
266+
}
267+
268+
$message = \sprintf(
269+
'Minimum line coverage of %01.2F%% %s. Current coverage: %s (%d/%d lines).',
270+
$minimumCoverage,
271+
$coverageSummary->percentage() >= $minimumCoverage ? 'satisfied' : 'was not met',
272+
$coverageSummary->percentageAsString(),
273+
$coverageSummary->executedLines(),
274+
$coverageSummary->executableLines(),
275+
);
276+
277+
if ($coverageSummary->percentage() >= $minimumCoverage) {
278+
$output->writeln('<info>' . $message . '</info>');
279+
280+
return self::SUCCESS;
281+
}
282+
283+
$output->writeln('<error>' . $message . '</error>');
284+
285+
return self::FAILURE;
286+
}
159287
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\PhpUnit\Coverage;
20+
21+
/**
22+
* Represents the line coverage summary extracted from a PHPUnit `coverage-php` report.
23+
*/
24+
final readonly class CoverageSummary
25+
{
26+
/**
27+
* @param int $executedLines the number of executable lines that were covered
28+
* @param int $executableLines the total number of executable lines
29+
*/
30+
public function __construct(
31+
private int $executedLines,
32+
private int $executableLines,
33+
) {}
34+
35+
/**
36+
* @return int the number of covered executable lines
37+
*/
38+
public function executedLines(): int
39+
{
40+
return $this->executedLines;
41+
}
42+
43+
/**
44+
* @return int the total number of executable lines
45+
*/
46+
public function executableLines(): int
47+
{
48+
return $this->executableLines;
49+
}
50+
51+
/**
52+
* @return float the executed line coverage percentage
53+
*/
54+
public function percentage(): float
55+
{
56+
if (0 === $this->executableLines) {
57+
return 100.0;
58+
}
59+
60+
return ($this->executedLines / $this->executableLines) * 100;
61+
}
62+
63+
/**
64+
* @return string the formatted executed line coverage percentage
65+
*/
66+
public function percentageAsString(): string
67+
{
68+
return \sprintf('%01.2F%%', $this->percentage());
69+
}
70+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\PhpUnit\Coverage;
20+
21+
use RuntimeException;
22+
use SebastianBergmann\CodeCoverage\CodeCoverage;
23+
24+
use function is_file;
25+
26+
/**
27+
* Loads line coverage metrics from the serialized PHPUnit `coverage-php` output.
28+
*/
29+
final readonly class CoverageSummaryLoader implements CoverageSummaryLoaderInterface
30+
{
31+
/**
32+
* @param string $coverageReportPath the path to the PHPUnit `coverage-php` report
33+
*
34+
* @return CoverageSummary the extracted line coverage summary
35+
*/
36+
public function load(string $coverageReportPath): CoverageSummary
37+
{
38+
if (! is_file($coverageReportPath)) {
39+
throw new RuntimeException(\sprintf('PHPUnit coverage report not found: %s', $coverageReportPath));
40+
}
41+
42+
/** @var mixed $coverage */
43+
$coverage = require $coverageReportPath;
44+
45+
if (! $coverage instanceof CodeCoverage) {
46+
throw new RuntimeException(\sprintf('PHPUnit coverage report is invalid: %s', $coverageReportPath));
47+
}
48+
49+
$report = $coverage->getReport();
50+
51+
return new CoverageSummary($report->numberOfExecutedLines(), $report->numberOfExecutableLines());
52+
}
53+
}

0 commit comments

Comments
 (0)