Using @codeCoverageIgnore Deliberately Instead of Inflating Coverage
AI generated
@test
assert
PHPUnit · Code Coverage · Test Quality
Using @codeCoverageIgnore Deliberately
Why the annotation is a sharp tool that demands sparing use

@codeCoverageIgnore excludes lines of code from the coverage calculation, making it a legitimate tool for cases where real testability is neither meaningful nor possible, such as trivial getters or unreachable defensive code. But when the annotation gets spread generously over code that is hard to test but genuinely worth testing, an honest measurement tool turns into an inflated number that gives the team a false sense of safety.

14 min read Code Coverage · Test Quality · Metrics PHPUnit 10 · 11 · PHP 8.x

1. What @codeCoverageIgnore actually does technically

Code coverage measures which portion of production code was actually executed during a test run, usually at the line, branch, or method level. The @codeCoverageIgnore annotation, placed above a single method, a class, or an individual code block, instructs the coverage driver to skip the affected lines entirely in the calculation, as if they did not exist at all. Neither their execution nor their absence then factors into the reported percentage.

That fundamentally distinguishes @codeCoverageIgnore from a simply untested code block: untested code lowers the coverage number and makes the gap visible, ignored code instead disappears completely from the statistics, as if it never existed. That exact property is what makes the annotation both useful for genuine edge cases and dangerous when applied thoughtlessly to artificially inflate a number.

2. Legitimate use: trivial getters and setters

The most common and least controversial use case for @codeCoverageIgnore is getters and setters that genuinely do nothing more than return or store a value unchanged. Such code contains no logic that could fail, so a dedicated test for it essentially only tests the PHP language itself, not any actual domain logic. Many projects therefore deliberately skip testing each of these getters individually.

The important qualifier is genuinely trivial: as soon as a getter contains additional logic, such as a type conversion, a computation, or a conditional return depending on internal state, it is no longer the trivial case and ignoring it is no longer justified. The line is clearly drawn at any branching or computation in the method body, anything beyond that deserves a real test.


final class Money
{
    public function __construct(
        private readonly int $amountInCents,
        private readonly string $currency,
    ) {
    }

    /**
     * @codeCoverageIgnore
     */
    public function getAmountInCents(): int
    {
        return $this->amountInCents;
    }

    /**
     * @codeCoverageIgnore
     */
    public function getCurrency(): string
    {
        return $this->currency;
    }

    // Contains logic, so NO codeCoverageIgnore, this needs a real test.
    public function format(): string
    {
        $euros = number_format($this->amountInCents / 100, 2, ',', '.');

        return match ($this->currency) {
            'EUR' => $euros . ' EUR',
            'USD' => '$' . $euros,
            default => $euros . ' ' . $this->currency,
        };
    }
}

3. Legitimate use: unreachable defensive code

A second legitimate case is defensive code that exists for good reason but, under normal circumstances, truly can never be reached, for example a default clause in a match expression that throws an exception even though all known cases are already covered by the preceding branches. This code serves as protection against future changes, such as a new enum variant that someone forgets to handle, not as an actively used path in today's program.

An important qualifier applies here too: ignoring it is only justified if the path is genuinely unreachable, not merely hard to reach. An error path triggered only by a rare but entirely possible network failure, for instance, is reachable and should be simulated and tested with a mock instead of being prematurely ignored just because setting up the test takes a bit more effort.


enum ShippingMethod: string
{
    case Standard = 'standard';
    case Express = 'express';
    case PickupInStore = 'pickup';
}

final class ShippingCostCalculator
{
    public function calculate(ShippingMethod $method, int $weightInGrams): int
    {
        return match ($method) {
            ShippingMethod::Standard => 499,
            ShippingMethod::Express => 999,
            ShippingMethod::PickupInStore => 0,
            // @codeCoverageIgnoreStart
            // Protection against future enum variants that get forgotten
            // here. Unreachable with the current three cases.
            default => throw new \LogicException('Unhandled shipping method'),
            // @codeCoverageIgnoreEnd
        };
    }
}

4. Edge cases that call for caution

Between the clearly legitimate and the clearly problematic cases lies a gray zone that deserves special attention. One example is code that reads the system clock or a random number generator: instead of skipping the corresponding branch with @codeCoverageIgnore, the clock or the random generator can almost always be controlled through an injectable abstraction and thus still tested, even if with somewhat more effort than a trivial example.

Another edge case is code for very rare but fundamentally reproducible error conditions, such as a database connection dropping mid-transaction. Such code can absolutely be tested with a mocked repository that deliberately throws an exception. The temptation to ignore it instead, simply because the test setup is more involved than a simple unit test, is something a team should be aware of and actively push back on.

5. Directory-level exclusion as an alternative to @codeCoverageIgnore

For entire categories of code that fundamentally never make sense to cover with unit tests, such as automatically generated migration files or pure DTO classes without any logic, @codeCoverageIgnore at every individual spot is the wrong lever. A directory-level exclusion in phpunit.xml is a better fit here, via the source block's exclude section, which keeps whole folders out of the coverage calculation from the start, without having to mark every single file manually.

This approach is more transparent than many scattered @codeCoverageIgnore annotations, because the exception lives in one central, clearly visible place in the configuration instead of being spread across dozens of files. The same discipline still applies here: only exclude directories that genuinely contain no logic worth testing, not directories that are merely inconvenient to test.


<!-- phpunit.xml -->
<phpunit bootstrap="vendor/autoload.php">
    <source>
        <include>
            <directory>src</directory>
        </include>
        <exclude>
            <directory>src/Migrations</directory>
            <directory>src/Generated</directory>
        </exclude>
    </source>
</phpunit>

6. The risk of coverage vanity metrics

The real risk factor with @codeCoverageIgnore emerges when the annotation is not applied out of careful judgment, but as a quick way to boost a coverage number, for example because a team target of 90 percent coverage is getting closer and hard-to-test but genuinely worthwhile code simply gets hidden. This approach produces a number that formally looks impressive but says less and less about actual test coverage.

What makes this especially insidious is that the distortion reinforces itself: once a team has learned that @codeCoverageIgnore is a convenient way to make uncomfortable gaps disappear, the annotation gets reached for even faster on the next difficult test case, because the path of least resistance is already established. The end result is a codebase with high reported coverage but many genuinely untested, critical paths that nobody perceives as a gap anymore.

7. Code review as a control mechanism

Because @codeCoverageIgnore can be misused so easily, every newly added use of the annotation should be explicitly addressed in code review, similar to a @phpstan-ignore-next-line or a try/catch with an empty catch block. A reviewer should specifically ask why the affected code is not testable, and check whether one of the two legitimate categories, trivial getter/setter or genuinely unreachable defensive code, actually applies.

A simple but effective practice is to require every @codeCoverageIgnore annotation to carry a short justification in the same comment block, as already shown in the shipping calculator example above. That justification forces the author to consciously reconsider the reasoning, and immediately gives any later reader context without having to research the code's history.


final class LegacyPriceFormatter
{
    public function format(mixed $rawPrice): string
    {
        if (!is_numeric($rawPrice)) {
            // @codeCoverageIgnoreStart
            // Safeguard for legacy callers from the old ERP export.
            // In all known call paths $rawPrice is already numeric,
            // the import process (see ticket ERP-204) has guaranteed this since 2024.
            throw new \InvalidArgumentException('Price must be numeric');
            // @codeCoverageIgnoreEnd
        }

        return number_format((float) $rawPrice, 2, ',', '.') . ' EUR';
    }
}

8. Setting coverage targets thoughtfully

A large part of @codeCoverageIgnore misuse can be avoided indirectly by formulating realistic, differentiated coverage targets for the team from the start. A blanket target of 100 percent coverage across the entire codebase almost inevitably creates pressure to generously ignore edge cases, since some code paths are genuinely impractical or impossible to fully reach. A more realistic target usually falls between 80 and 95 percent, depending on the codebase, with clearly documented exceptions for generated code or pure value objects.

It also helps to view coverage not as a pure percentage target, but together with the number of active @codeCoverageIgnore occurrences. A team that tracks both numbers together, for example through a simple grep script in the CI pipeline that counts ignore occurrences and warns on a sudden increase, spots vanity metrics far earlier than a team that only looks at the raw percentage.


#!/usr/bin/env bash
# ci/check-coverage-ignore-count.sh
# Warns when the number of codeCoverageIgnore occurrences jumps sharply.

set -euo pipefail

CURRENT_COUNT=$(grep -r "@codeCoverageIgnore" --include="*.php" src/ | wc -l)
BASELINE_COUNT=$(cat ci/coverage-ignore-baseline.txt)

if [ "$CURRENT_COUNT" -gt "$((BASELINE_COUNT + 5))" ]; then
    echo "Warning: codeCoverageIgnore occurrences rose from $BASELINE_COUNT to $CURRENT_COUNT."
    echo "Please review whether every new ignore is genuinely justified."
    exit 1
fi

echo "OK: $CURRENT_COUNT codeCoverageIgnore occurrences (baseline: $BASELINE_COUNT)."

9. Team guidelines for using @codeCoverageIgnore

For @codeCoverageIgnore to remain a precise tool rather than a misused one over time, a team needs a short, written guideline listing the acceptable categories, essentially the two described in this article: trivial getters/setters without any logic, and genuinely unreachable defensive code. Anything outside those categories should be critically questioned in review before the annotation is accepted.

It is also worth periodically reviewing all existing @codeCoverageIgnore occurrences in the project, for example twice a year, to check whether anything has changed at the affected code locations that no longer justifies the ignore, for instance because a previously unreachable branch has since become reachable due to a new requirement. The table below summarizes the key categories and their assessment.

Case Use of @codeCoverageIgnore Justification Alternative
Trivial getter/setter without logic Justified No branching path, only tests PHP itself None needed
Unreachable default branch in match/switch Justified, with a comment explaining why Serves as protection against future changes None needed, document the justification
Rare but reproducible error path Not justified Simulatable with a mock, therefore testable Test the error path deliberately with a mocked collaborator
Code ignored purely to inflate coverage Not justified Distorts the meaning of the metric Write a test or simplify the code

Mironsoft

Test automation, Magento quality assurance, and CI integration

Tests that catch real bugs instead of just turning green?

We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.

Test Audit

Reviewing existing suites for mocking antipatterns and blind spots.

Test Strategy

Meaningfully combining unit, integration, and MFTF tests for Magento projects.

CI Integration

Setting up fast, reliable test runs in GitLab CI or GitHub Actions.

10. Summary

@codeCoverageIgnore in PHPUnit: The Essentials at a Glance

Effect

@codeCoverageIgnore removes marked lines entirely from the coverage calculation, as if they did not exist.

Legitimate cases

Trivial getters and setters without logic, and genuinely unreachable defensive code, such as a default branch guarding against future enum variants.

Main risk

Liberal use on hard-to-test but genuinely worthwhile code produces an inflated number with no real meaning.

Control

Every new ignore belongs in code review with a justification, and periodically counting all occurrences serves as an early warning system.

11. FAQ: @codeCoverageIgnore in PHPUnit: The Essentials at a Glance

1What exactly does @codeCoverageIgnore do in the coverage calculation?
The marked lines are removed entirely from the calculation, neither their execution nor their absence factors into the reported percentage, as if they never existed.
2Is it fine to mark every getter and setter with @codeCoverageIgnore?
Only as long as they are genuinely trivial and contain no logic at all. As soon as a getter contains a computation, type conversion, or branch, ignoring it is no longer justified and a real test is needed.
3When is a default branch in a match expression truly unreachable?
When all known cases are already covered by the preceding branches, for example every variant of an enum, and the default branch only serves as protection against future, currently unknown cases.
4Should I ignore rare error paths or test them?
Test them, as long as they are reproducible, for example with a mocked collaborator that deliberately throws an exception. Ignoring is only justified for genuinely unreachable code, not merely hard-to-reach code.
5How do I spot coverage vanity metrics in an existing project?
A mismatch between a high reported coverage number and many @codeCoverageIgnore occurrences is a clear warning sign, especially if the number of ignores grows sharply over time.
6Should @codeCoverageIgnore be treated specially in code review?
Yes, similar to a @phpstan-ignore-next-line, every new ignore should be explicitly questioned and carry a short justification in the comment, to catch misuse early.
7Is a 100 percent coverage target a good idea?
Usually not, since it almost inevitably creates pressure to generously ignore edge cases. A realistic target between 80 and 95 percent with documented exceptions is usually more sensible in practice.
8Can I automatically monitor the number of @codeCoverageIgnore occurrences?
Yes, a simple script that counts occurrences and compares them against a baseline can easily be integrated into the CI pipeline and raise an alert on a sudden increase.
9Is there a difference between @codeCoverageIgnore on a method versus a single line?
Yes, @codeCoverageIgnoreStart and @codeCoverageIgnoreEnd let you hide a single code block within a method, while the annotation above a method affects the entire method.
10What is the difference between ignored code and simply untested code?
Untested code visibly lowers the coverage number and makes the gap transparent, ignored code disappears completely from the statistics and becomes invisible to everyone.