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.
Table of Contents
- 1. What @codeCoverageIgnore actually does technically
- 2. Legitimate use: trivial getters and setters
- 3. Legitimate use: unreachable defensive code
- 4. Edge cases that call for caution
- 5. Directory-level exclusion as an alternative to @codeCoverageIgnore
- 6. The risk of coverage vanity metrics
- 7. Code review as a control mechanism
- 8. Setting coverage targets thoughtfully
- 9. Team guidelines for using @codeCoverageIgnore
- 10. Summary
- 11. FAQ
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.