Bug: `#[CoversNothing]` is ignored in Cest tests with PHPUnit 13 / php-code-coverage 12
##Bug: #[CoversNothing] is ignored in Cest tests with PHPUnit 13 / php-code-coverage 12
Environment
codeception/codeception: ^5.3.5phpunit/phpunit: ^13.0phpunit/php-code-coverage: ^12.0
Steps to reproduce
use PHPUnit\Framework\Attributes\CoversNothing; #[CoversNothing] class SomeCest { public function someTest(\AcceptanceTester $I): void { // ... } }
Run: vendor/bin/codecept run --coverage
Expected: test does not contribute to code coverage.
Actual: TypeError crash — or, if getLinesToBeCovered() returns [],
the test silently contributes to coverage as if the attribute were absent.
Root cause (two-part)
Part 1 — Cest::getLinesToBeCovered() does not handle CoversNothing
PHPUnit\Metadata\Api\CodeCoverage::coversTargets() in PHPUnit 13 handles only
CoversClass, CoversMethod, CoversTrait, etc.
It does not handle #[CoversNothing] — it simply returns an empty TargetCollection.
The correct PHPUnit 13 API for CoversNothing is shouldCodeCoverageBeCollectedFor():
// PHPUnit 13 — PHPUnit\Metadata\Api\CodeCoverage public function shouldCodeCoverageBeCollectedFor(TestCase $test): bool { if ($parser->forClass($test::class)->isCoversNothing()->isNotEmpty()) { return false; } return true; }
But Cest::getLinesToBeCovered() never calls it, so CoversNothing is lost.
Part 2 — CodeCoverage trait does not handle false return value for php-code-coverage ≥ 12
Historically getLinesToBeCovered() returned false for @coversNothing.
In Feature/CodeCoverage.php, when php-code-coverage >= 12, the result is passed
directly to TargetCollection::fromArray(), which does not accept false:
Proposed fix
1. src/Codeception/Test/Cest.php — check CoversNothing before delegating to coversTargets():
public function getLinesToBeCovered(): array|bool { if (PHPUnitVersion::series() < 10) { return TestUtil::getLinesToBeCovered($this->testClass, $this->testMethod); } $metadata = \PHPUnit\Metadata\Parser\Registry::parser() ->forClassAndMethod($this->testClass, $this->testMethod); if ($metadata->isCoversNothing()->isNotEmpty()) { return false; } if (version_compare(CodeCoverageVersion::id(), '12', '>=')) { return (new CodeCoverage())->coversTargets($this->testClass, $this->testMethod)->asArray(); } return (new CodeCoverage())->linesToBeCovered($this->testClass, $this->testMethod); }
2. src/Codeception/Test/Feature/CodeCoverage.php — handle false before calling TargetCollection::fromArray():
if (version_compare(CodeCoverageVersion::id(), '12', '>=')) { $tcClass = 'SebastianBergmann\\CodeCoverage\\Test\\Target\\TargetCollection'; if (class_exists($tcClass) && method_exists($tcClass, 'fromArray')) { if ($linesToBeCovered === false) { $codeCoverage->stop(false, $status); return; } $linesToBeCovered = $tcClass::fromArray($linesToBeCovered); $linesToBeUsed = $tcClass::fromArray($linesToBeUsed); } } $codeCoverage->stop(true, $status, $linesToBeCovered, $linesToBeUsed);
Both changes are needed: the first makes CoversNothing detectable again,
the second prevents the TypeError crash when the false value reaches fromArray().