◐ Shell
clean mode source ↗

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.5
  • phpunit/phpunit: ^13.0
  • phpunit/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().