usable for large Magento projects
Code coverage sounds like a simple concept, but it quickly becomes a bottleneck in large Magento 2 projects: Xdebug slows the test suite down by a factor of five to ten, reports become huge, and without proper configuration you end up measuring the wrong thing. PCOV, targeted whitelist configuration and CI thresholds turn coverage into a useful tool instead of a bottleneck.
Table of Contents
- 1. Why code coverage is so difficult in Magento projects
- 2. Xdebug vs. PCOV: the right driver for the right use case
- 3. PHPUnit configuration for meaningful coverage
- 4. Source filter: measuring only relevant code
- 5. Generating Clover reports and evaluating them in CI
- 6. Setting sensible coverage thresholds
- 7. Using coverage annotations deliberately
- 8. Local HTML reports for fast feedback
- 9. Coverage strategies compared
- 10. Summary
- 11. FAQ
1. Why code coverage is so difficult in Magento projects
Magento 2 is one of the most complex PHP applications in the e-commerce space. Thousands of classes, deep dependency-injection hierarchies, generated proxy and factory classes and a modular architecture make it difficult to measure code coverage in a meaningful way. Anyone naively running vendor/bin/phpunit --coverage-html coverage/ often waits twenty minutes, only to receive a report that mainly shows how much of Magento's own framework code was executed rather than their own code.
The real problem is the missing separation between measured code and executed code. By default, PHPUnit measures every class loaded during a test, including all Magento core classes, generated classes and vendor libraries. A single integration test can touch hundreds of classes that nobody wants to test. Without clear configuration the number is meaningless: 45 percent coverage across the whole project says little about whether your own business code is sufficiently covered.
On top of that comes the performance aspect: Xdebug as a coverage driver is reliable, but slow. In a Magento project with several hundred unit tests, Xdebug can push the total runtime from thirty seconds to five minutes. In a CI pipeline that runs on every commit, that is a significant bottleneck. The solution lies in a combination of the right coverage driver, precise source-filter configuration and a well thought out threshold concept that treats coverage as a continuous metric rather than a one-off target.
2. Xdebug vs. PCOV: the right driver for the right use case
PHPUnit offers three drivers for code coverage: Xdebug, PCOV and phpdbg. Xdebug is the most complete, since besides coverage it also supports debugging, profiling and remote debugging. PCOV is a dedicated coverage driver without debugging functionality that produces significantly less overhead. phpdbg is built into PHP, but has been marked deprecated since PHPUnit 10 and should no longer be used.
In practice this means: for local development with debugging, Xdebug is the right choice. You can enable coverage when you need it, and use debugging when investigating errors. For CI pipelines without a debugging need, PCOV is the better option: it is up to three times faster than Xdebug and produces identical coverage data. It is important that both extensions are never active at the same time. PCOV and Xdebug are mutually exclusive, and PHPUnit issues a warning if Xdebug is active but coverage was not requested.
# php.ini for CI environment -- PCOV only, no Xdebug
extension=pcov.so
pcov.enabled=1
pcov.directory=/var/www/html/app/code
# Xdebug for local development (separate php.ini or .env)
# xdebug.mode=coverage (only when coverage is needed)
# xdebug.mode=debug (for normal development)
# xdebug.mode=off (for maximum performance)
# PHPUnit invocation with explicit driver
vendor/bin/phpunit \
--coverage-driver pcov \
--coverage-clover build/coverage/clover.xml \
--testsuite unit
# Control Xdebug mode via environment variable
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
PCOV has one important limitation: it only measures code that lies within the configured pcov.directory. That is also an advantage, since it forces you to measure only your own code. Anyone who sets pcov.directory=/var/www/html/app/code/Vendor/Module measures exclusively their own modules and immediately gets more meaningful numbers. In Docker-based Magento setups (such as the Mark Shust setup), PCOV is installed as an additional PHP extension and Xdebug is switched off in the CI configuration.
3. PHPUnit configuration for meaningful coverage
The PHPUnit configuration file phpunit.xml is the central place to control coverage behavior. In PHPUnit 10 and 11 the configuration structure changed fundamentally: the old <filter> configuration was replaced by <source>, and coverage percentages are now defined in the <coverage> block. Anyone working on an older Magento project that still uses PHPUnit 9 needs to know the different configuration formats and use the right one depending on the project's state.
The configuration of pathCoverage is particularly important. Branch coverage and path coverage provide more detailed information about which branches in the code were executed, not just which lines. Code with many if-else constructs can show 100 percent line coverage even though only one path through each condition was tested. Branch coverage reveals these gaps. The performance overhead for branch coverage with PCOV is minimal and justifies the significantly more meaningful data.
<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml -- PHPUnit 11 configuration for Magento 2 module testing -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="dev/tests/unit/framework/bootstrap.php"
cacheDirectory=".phpunit.cache"
colors="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="unit">
<directory>app/code/Mironsoft/*/Test/Unit</directory>
</testsuite>
</testsuites>
<source restrictDeprecations="true">
<include>
<directory suffix=".php">app/code/Mironsoft</directory>
</include>
<exclude>
<directory>app/code/Mironsoft/*/Test</directory>
<directory>app/code/Mironsoft/*/Setup</directory>
</exclude>
</source>
<coverage>
<report>
<clover outputFile="build/coverage/clover.xml"/>
<html outputDirectory="build/coverage/html" lowUpperBound="50" highLowerBound="80"/>
</report>
</coverage>
</phpunit>
4. Source filter: measuring only relevant code
The most important lever for meaningful coverage numbers is the precise configuration of the source filter. In PHPUnit 11, the <source> block defines which files are included in the coverage calculation. The most common source of error: the filter is too broad and includes classes that reasonably cannot or should not be tested, such as database migrations, setup scripts, DI configurations and generated classes.
For Magento modules there are clear categories of code that should be excluded from coverage measurement: Setup/ directories with InstallSchema and UpgradeData classes, since these cannot be covered by unit tests. Test/ directories themselves, since test classes are not production classes. Configuration classes such as plugins, which are not testable without a real Magento context. With a precise exclusion of these categories, the coverage number rises to a baseline that shows real progress instead of producing statistical noise.
Another important aspect is the use of the #[CoversClass] attribute, or the @covers annotation, in test classes. This restricts coverage measurement for a test to a specific class, instead of counting every class touched during the test. This prevents a test from increasing the coverage of a class it is not actually meant to test directly, a common problem in tests that instantiate many dependencies.
5. Generating Clover reports and evaluating them in CI
The Clover format is the standardized XML format for coverage data and is understood by most CI systems and code-coverage platforms such as Codecov, Coveralls and SonarQube. PHPUnit generates Clover reports with the --coverage-clover option or via the phpunit.xml configuration. The report contains detailed line counters for every file and every method: how often each line was executed and whether conditions are fully covered.
In a GitLab CI pipeline, the Clover report can be uploaded directly as a coverage artifact and used for the coverage badge in the repository. GitHub Actions supports Codecov as a service that automatically analyzes Clover reports and displays the changed files with their coverage as a PR comment. The goal is always the same: not to focus on the absolute coverage number of the project, but to ensure that newly added code is sufficiently tested.
# .gitlab-ci.yml -- coverage integration with PHPUnit and PCOV
test:unit:coverage:
stage: test
image: php:8.4-cli
before_script:
- pecl install pcov
- docker-php-ext-enable pcov
- composer install --no-interaction --prefer-dist
script:
- php -d pcov.enabled=1
-d pcov.directory=app/code/Mironsoft
vendor/bin/phpunit
--coverage-clover build/coverage/clover.xml
--coverage-text
--colors=never
--testsuite unit
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: build/coverage/cobertura.xml
paths:
- build/coverage/
expire_in: 7 days
only:
- merge_requests
- main
6. Setting sensible coverage thresholds
Coverage thresholds are a double-edged sword. Too low thresholds give no assurance; too high ones create pressure to write tests that provide no real value, so-called coverage-driven tests that execute lines rather than verify behavior. The most sensible strategy for Magento projects is a tiered threshold concept: different minimum coverage for different code categories.
Business logic in service classes and view models should aim for high coverage of 80 to 90 percent. Infrastructure code such as repositories and plugin classes that are deeply integrated with Magento internals can be lowered to 50 to 60 percent, since complete unit tests are hardly achievable here without significant mocking effort. Configuration classes are excluded entirely from coverage measurement. In newer versions, PHPUnit no longer allows enforcing minimum coverage directly in the configuration. Instead, you check the Clover report in the CI script with a small PHP script or a tool such as infection/infection.
7. Using coverage annotations deliberately
The #[CoversClass(PriceCalculator::class)] annotation in PHPUnit 10 and 11 is more than a documentation aid. It restricts the coverage calculation for this test to the specified class. This means: even if the test internally instantiates further classes, it only increases coverage of the declared class. This prevents tests from indirectly increasing the coverage of classes that should actually be covered by their own tests.
The #[CoversNothing] annotation is the counterpart: it explicitly marks tests as coverage-irrelevant. This is useful for smoke tests and integration tests whose primary purpose is to check overall functionality, not to increase the coverage of individual classes. With coversDefaultClass at the test-class level, the default class for all test methods in a test class can be set, so that not every method needs to be annotated individually.
<?php
declare(strict_types=1);
namespace Mironsoft\Pricing\Test\Unit\Model;
use Mironsoft\Pricing\Model\PriceCalculator;
use Mironsoft\Pricing\Model\TaxResolver;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for PriceCalculator.
* Coverage is restricted to PriceCalculator -- TaxResolver is mocked.
*/
#[CoversClass(PriceCalculator::class)]
final class PriceCalculatorTest extends TestCase
{
private PriceCalculator $subject;
private TaxResolver&MockObject $taxResolver;
protected function setUp(): void
{
$this->taxResolver = $this->createMock(TaxResolver::class);
$this->subject = new PriceCalculator($this->taxResolver);
}
#[Test]
#[DataProvider('provideGrossPriceData')]
public function calculatesGrossPriceCorrectly(
float $net,
float $taxRate,
float $expectedGross
): void {
$this->taxResolver
->method('getRateForProduct')
->willReturn($taxRate);
$result = $this->subject->calculateGross(productId: 1, netPrice: $net);
self::assertEqualsWithDelta($expectedGross, $result, 0.001);
}
public static function provideGrossPriceData(): array
{
return [
'standard_vat' => [100.00, 0.19, 119.00],
'reduced_vat' => [100.00, 0.07, 107.00],
'zero_rated' => [100.00, 0.00, 100.00],
];
}
}
8. Local HTML reports for fast feedback
PHPUnit's HTML report is the most powerful tool for local coverage analysis. For each file it shows exactly which lines were executed (green), which were not (red) and which are fully covered by branch coverage. Particularly helpful is the drill-down function: from the project overview to the file, from the file to the individual method, from the method to every single statement. This makes it possible to identify the areas with the lowest coverage in just a few clicks.
For maximum performance during local development, a two-stage workflow is recommended: first the test suite runs without coverage for fast feedback (under thirty seconds), then targeted for individual modules with coverage when investigating a specific area of code. PHPUnit supports this with the --filter flag and the --coverage-filter argument, which restricts coverage measurement to specific directories without changing the test suite. The generated HTML report opens directly in the browser and stays cached between sessions as long as the source files remain unchanged.
9. Coverage strategies compared
There are several competing approaches to handling coverage in large PHP projects. The choice of approach directly affects how meaningful the numbers are and how much overhead the test suite produces.
| Strategy | Coverage driver | Performance | Recommendation |
|---|---|---|---|
| Full project coverage | Xdebug (coverage mode) | Slow (5-10x) | Only for final reports |
| Module-specific coverage | PCOV with pcov.directory | Fast (1.5-2x) | Standard for CI pipelines |
| Mutation testing | Infection + PCOV | Very slow | Nightly build, not per commit |
| Diff coverage (changed code only) | PCOV + diff-filter tool | Very fast | Ideal for PR checks |
| No coverage (test result only) | No driver | Maximally fast | Local development workflow |
Diff coverage is a particularly valuable strategy for projects with a long history and heterogeneous existing coverage. Instead of trying to increase the coverage of the entire project, it checks whether the code newly added or changed in the current pull request is sufficiently tested. Tools such as diff-cover combine the Git diff with the Clover report and show only coverage gaps in changed files. This turns coverage into a quality gate for new code, without legacy code that has no tests blocking the workflow.
Mironsoft
PHPUnit, code coverage and test infrastructure for Magento 2
Code coverage that delivers real value?
We set up PHPUnit and PCOV for your Magento project, configure meaningful coverage reports and integrate thresholds into your CI/CD pipeline, without overhead and without misleading numbers.
Coverage audit
Analysis of the existing configuration, identifying incorrect thresholds and coverage gaps in business code
PCOV setup
Setting up PCOV in a Docker environment, resolving Xdebug conflicts and optimizing the CI pipeline for fast coverage
Clover integration
Integrating Clover reports into GitLab CI or GitHub Actions, setting up Codecov connection and coverage badge
10. Summary
Code coverage in large Magento projects is only a useful tool when configured correctly. The most important step is switching from Xdebug to PCOV in CI environments: three to five times faster coverage measurement with identical results. Precise source-filter configuration via the <source> block in phpunit.xml ensures that only your own business code is measured rather than Magento core or generated classes. Clover reports in CI pipelines provide the data foundation for coverage badges, PR comments and historical trends.
The most important lesson for practice: absolute coverage numbers at the project level are less valuable than differentiated thresholds for different code categories and diff coverage for pull requests. A new module with 85 percent coverage on business logic is more valuable than a project with 70 percent coverage on everything, including database migrations and configuration classes. Coverage annotations such as #[CoversClass] refine the measurement further and prevent tests from unintentionally increasing the coverage of classes that deserve their own tests.
PHPUnit coverage for Magento, the essentials at a glance
Driver choice
PCOV for CI (3-5x faster than Xdebug), Xdebug for local debugging. Never leave both active at the same time.
Source filter
Measure only your own modules. Explicitly exclude setup, test and configuration classes. Set pcov.directory narrowly.
Clover reports
Store Clover XML in CI as an artifact. Use Codecov or GitLab coverage report for PR feedback.
Thresholds
Differentiated thresholds: business logic 80-90%, infrastructure 50-60%. Diff coverage as a PR quality gate.