From 3899490caf7b744b9a50f94c4bd5c1b93c2f3307 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Tue, 17 Mar 2026 14:01:35 +0100 Subject: [PATCH 1/3] Initial work on custom code coverage driver support --- phpunit.xsd | 1 + src/Runner/CodeCoverage.php | 72 ++++++++++++++++++- .../Exception/CodeCoverageDriverException.php | 21 ++++++ src/TextUI/Configuration/Configuration.php | 24 ++++++- ...deCoverageDriverNotConfiguredException.php | 21 ++++++ src/TextUI/Configuration/Merger.php | 7 ++ .../Xml/CodeCoverage/CodeCoverage.php | 26 ++++++- .../Xml/DefaultConfiguration.php | 1 + src/TextUI/Configuration/Xml/Loader.php | 4 ++ .../configuration_codecoverage_driver.xml | 15 ++++ .../phpunit-class-does-not-exist.xml | 23 ++++++ .../phpunit-class-does-not-extend-driver.xml | 23 ++++++ .../phpunit-class-is-not-instantiable.xml | 23 ++++++ ...with-no-required-constructor-arguments.xml | 23 ++++++ ...it-with-required-constructor-arguments.xml | 23 ++++++ .../src/AbstractCustomDriver.php | 16 +++++ .../code-coverage-driver/src/CustomDriver.php | 35 +++++++++ .../src/CustomDriverWithFilter.php | 43 +++++++++++ .../_files/code-coverage-driver/src/Foo.php | 18 +++++ .../code-coverage-driver/src/NotADriver.php | 14 ++++ .../code-coverage-driver/tests/FooTest.php | 20 ++++++ .../code-coverage-driver/vendor/autoload.php | 7 ++ ...-coverage-driver-class-does-not-exist.phpt | 26 +++++++ ...e-driver-class-does-not-extend-driver.phpt | 26 +++++++ ...rage-driver-class-is-not-instantiable.phpt | 26 +++++++ ...ith-no-required-constructor-arguments.phpt | 34 +++++++++ ...r-with-required-constructor-arguments.phpt | 34 +++++++++ .../Configuration/ConfigurationTest.php | 19 +++++ .../unit/TextUI/Configuration/MergerTest.php | 12 ++++ .../TextUI/Configuration/Xml/LoaderTest.php | 21 ++++++ 30 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 src/Runner/Exception/CodeCoverageDriverException.php create mode 100644 src/TextUI/Configuration/Exception/CodeCoverageDriverNotConfiguredException.php create mode 100644 tests/_files/configuration_codecoverage_driver.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-exist.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-extend-driver.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-is-not-instantiable.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-no-required-constructor-arguments.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-required-constructor-arguments.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/src/AbstractCustomDriver.php create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriver.php create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFilter.php create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/src/Foo.php create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/src/NotADriver.php create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/tests/FooTest.php create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php create mode 100644 tests/end-to-end/code-coverage/code-coverage-driver-class-does-not-exist.phpt create mode 100644 tests/end-to-end/code-coverage/code-coverage-driver-class-does-not-extend-driver.phpt create mode 100644 tests/end-to-end/code-coverage/code-coverage-driver-class-is-not-instantiable.phpt create mode 100644 tests/end-to-end/code-coverage/code-coverage-driver-with-no-required-constructor-arguments.phpt create mode 100644 tests/end-to-end/code-coverage/code-coverage-driver-with-required-constructor-arguments.phpt diff --git a/phpunit.xsd b/phpunit.xsd index ec7af3048bf..07b01d6fc3e 100644 --- a/phpunit.xsd +++ b/phpunit.xsd @@ -74,6 +74,7 @@ + diff --git a/src/Runner/CodeCoverage.php b/src/Runner/CodeCoverage.php index e0f5a402172..24c4a348af0 100644 --- a/src/Runner/CodeCoverage.php +++ b/src/Runner/CodeCoverage.php @@ -10,7 +10,9 @@ namespace PHPUnit\Runner; use function assert; +use function class_exists; use function implode; +use function is_subclass_of; use function sprintf; use function sys_get_temp_dir; use DateTimeImmutable; @@ -20,6 +22,7 @@ use PHPUnit\TextUI\Configuration\Configuration; use PHPUnit\TextUI\Output\Printer; use PHPUnit\Util\Filesystem; +use ReflectionClass; use SebastianBergmann\CodeCoverage\Driver\Driver; use SebastianBergmann\CodeCoverage\Driver\Granularity; use SebastianBergmann\CodeCoverage\Driver\Selector; @@ -82,10 +85,17 @@ public function init(Configuration $configuration, CodeCoverageFilterRegistry $c return CodeCoverageInitializationStatus::NOT_REQUESTED; } + $coverageDriver = null; + + if ($configuration->hasCoverageDriver()) { + $coverageDriver = $configuration->coverageDriver(); + } + $this->activate( $codeCoverageFilterRegistry->get(), $configuration->branchCoverage(), $configuration->pathCoverage(), + $coverageDriver, ); if (!$this->isActive()) { @@ -512,7 +522,7 @@ public function warnIfFilterIsNotConfigured(CodeCoverageFilterRegistry $codeCove $this->deactivate(); } - private function activate(Filter $filter, bool $branchCoverage, bool $pathCoverage): void + private function activate(Filter $filter, bool $branchCoverage, bool $pathCoverage, ?string $driverClass = null): void { try { $granularity = Granularity::Line; @@ -534,7 +544,11 @@ private function activate(Filter $filter, bool $branchCoverage, bool $pathCovera $granularity = Granularity::LineBranchAndPath; } - $this->driver = (new Selector)->select($filter, $granularity); + if ($driverClass !== null) { + $this->driver = $this->instantiateDriver($driverClass, $filter, $granularity); + } else { + $this->driver = (new Selector)->select($filter, $granularity); + } $this->codeCoverage = new \SebastianBergmann\CodeCoverage\CodeCoverage( $this->driver, @@ -554,6 +568,60 @@ private function activate(Filter $filter, bool $branchCoverage, bool $pathCovera } } + /** + * @phpstan-ignore return.internalClass + */ + private function instantiateDriver(string $driverClass, Filter $filter, Granularity $granularity): Driver + { + if (!class_exists($driverClass)) { + throw new CodeCoverageDriverException( + sprintf( + 'Configured code coverage driver class "%s" does not exist', + $driverClass, + ), + ); + } + + /** @phpstan-ignore classConstant.internalClass */ + if (!is_subclass_of($driverClass, Driver::class)) { + throw new CodeCoverageDriverException( + sprintf( + 'Configured code coverage driver class "%s" does not extend %s', + $driverClass, + /** @phpstan-ignore classConstant.internalClass */ + Driver::class, + ), + ); + } + + $reflection = new ReflectionClass($driverClass); + + if (!$reflection->isInstantiable()) { + throw new CodeCoverageDriverException( + sprintf( + 'Configured code coverage driver class "%s" is not instantiable', + $driverClass, + ), + ); + } + + $constructor = $reflection->getConstructor(); + + if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) { + $driver = $reflection->newInstance($filter); + } else { + $driver = $reflection->newInstance(); + } + + /** @phpstan-ignore instanceof.internalClass */ + assert($driver instanceof Driver); + + /** @phpstan-ignore method.internalClass */ + $driver->setGranularity($granularity); + + return $driver; + } + private function codeCoverageGenerationStart(Printer $printer, string $format): void { $printer->print( diff --git a/src/Runner/Exception/CodeCoverageDriverException.php b/src/Runner/Exception/CodeCoverageDriverException.php new file mode 100644 index 00000000000..cc85ced1afb --- /dev/null +++ b/src/Runner/Exception/CodeCoverageDriverException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner; + +use RuntimeException; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class CodeCoverageDriverException extends RuntimeException implements Exception +{ +} diff --git a/src/TextUI/Configuration/Configuration.php b/src/TextUI/Configuration/Configuration.php index 7bed93137af..74e39129bce 100644 --- a/src/TextUI/Configuration/Configuration.php +++ b/src/TextUI/Configuration/Configuration.php @@ -63,6 +63,7 @@ private Source $source; private bool $pathCoverage; private bool $branchCoverage; + private ?string $coverageDriver; /** * @var ?non-empty-string @@ -553,7 +554,7 @@ * @param null|non-empty-string $generateBaseline * @param non-negative-int $shortenArraysForExportThreshold */ - public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $cacheResult, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testResultCacheFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, int $coverageHtmlLowUpperBound, int $coverageHtmlHighLowerBound, string $coverageHtmlColorSuccessLow, string $coverageHtmlColorSuccessLowDark, string $coverageHtmlColorSuccessMedium, string $coverageHtmlColorSuccessMediumDark, string $coverageHtmlColorSuccessHigh, string $coverageHtmlColorSuccessHighDark, string $coverageHtmlColorSuccessBar, string $coverageHtmlColorSuccessBarDark, string $coverageHtmlColorWarning, string $coverageHtmlColorWarningDark, string $coverageHtmlColorWarningBar, string $coverageHtmlColorWarningBarDark, string $coverageHtmlColorDanger, string $coverageHtmlColorDangerDark, string $coverageHtmlColorDangerBar, string $coverageHtmlColorDangerBarDark, string $coverageHtmlColorBreadcrumbs, string $coverageHtmlColorBreadcrumbsDark, ?string $coverageHtmlCustomCssFile, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, bool $coverageTextShowUncoveredFiles, bool $coverageTextShowOnlySummary, ?string $coverageXml, bool $coverageXmlIncludeSource, bool $pathCoverage, bool $branchCoverage, bool $ignoreDeprecatedCodeUnitsFromCodeCoverage, bool $disableCodeCoverageIgnore, bool $disableCoverageTargeting, bool $failOnAllIssues, bool $failOnDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnPhpunitDeprecation, bool $doNotFailOnPhpunitNotice, bool $doNotFailOnPhpunitWarning, bool $doNotFailOnEmptyTestSuite, bool $doNotFailOnIncomplete, bool $doNotFailOnNotice, bool $doNotFailOnRisky, bool $doNotFailOnSkipped, bool $doNotFailOnWarning, int $stopOnDefect, int $stopOnDeprecation, ?string $specificDeprecationToStopOn, int $stopOnError, int $stopOnFailure, int $stopOnIncomplete, int $stopOnNotice, int $stopOnRisky, int $stopOnSkipped, int $stopOnWarning, bool $outputToStandardErrorStream, int $columns, bool $noExtensions, ?string $pharExtensionDirectory, array $extensionBootstrappers, bool $backupGlobals, bool $backupStaticProperties, bool $beStrictAboutChangesToGlobalState, bool $colors, bool $processIsolation, bool $enforceTimeLimit, int $defaultTimeLimit, int $diffContext, int $timeoutForSmallTests, int $timeoutForMediumTests, int $timeoutForLargeTests, bool $reportUselessTests, bool $strictCoverage, bool $requireCoverageContribution, bool $disallowTestOutput, bool $displayDetailsOnAllIssues, bool $displayDetailsOnIncompleteTests, bool $displayDetailsOnSkippedTests, bool $displayDetailsOnTestsThatTriggerDeprecations, bool $displayDetailsOnPhpunitDeprecations, bool $displayDetailsOnPhpunitNotices, bool $displayDetailsOnTestsThatTriggerErrors, bool $displayDetailsOnTestsThatTriggerNotices, bool $displayDetailsOnTestsThatTriggerWarnings, bool $reverseDefectList, bool $requireCoverageMetadata, bool $requireSealedMockObjects, bool $noProgress, bool $noResults, bool $noOutput, int $executionOrder, int $executionOrderDefects, bool $resolveDependencies, ?string $logfileTeamcity, ?string $logfileJunit, ?string $logfileOtr, bool $includeGitInformation, bool $includeGitInformationInOtrLogfile, ?string $logfileTestdoxHtml, ?string $logfileTestdoxText, ?string $logEventsText, ?string $logEventsVerboseText, bool $compactOutput, bool $teamCityOutput, bool $testDoxOutput, bool $testDoxOutputSummary, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, ?string $filter, ?string $excludeFilter, ?string $testIdFilterFile, ?string $testIdFilter, array $groups, array $excludeGroups, int $randomOrderSeed, int $repeat, int $retry, bool $includeUncoveredFiles, TestSuiteCollection $testSuite, string $includeTestSuite, string $excludeTestSuite, ?string $defaultTestSuite, bool $ignoreTestSelectionInXmlConfiguration, array $testSuffixes, Php $php, bool $controlGarbageCollector, int $numberOfTestsBeforeGarbageCollection, ?string $generateBaseline, bool $debug, bool $withTelemetry, int $shortenArraysForExportThreshold) + public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $cacheResult, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testResultCacheFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, int $coverageHtmlLowUpperBound, int $coverageHtmlHighLowerBound, string $coverageHtmlColorSuccessLow, string $coverageHtmlColorSuccessLowDark, string $coverageHtmlColorSuccessMedium, string $coverageHtmlColorSuccessMediumDark, string $coverageHtmlColorSuccessHigh, string $coverageHtmlColorSuccessHighDark, string $coverageHtmlColorSuccessBar, string $coverageHtmlColorSuccessBarDark, string $coverageHtmlColorWarning, string $coverageHtmlColorWarningDark, string $coverageHtmlColorWarningBar, string $coverageHtmlColorWarningBarDark, string $coverageHtmlColorDanger, string $coverageHtmlColorDangerDark, string $coverageHtmlColorDangerBar, string $coverageHtmlColorDangerBarDark, string $coverageHtmlColorBreadcrumbs, string $coverageHtmlColorBreadcrumbsDark, ?string $coverageHtmlCustomCssFile, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, bool $coverageTextShowUncoveredFiles, bool $coverageTextShowOnlySummary, ?string $coverageXml, bool $coverageXmlIncludeSource, bool $pathCoverage, bool $branchCoverage, ?string $coverageDriver, bool $ignoreDeprecatedCodeUnitsFromCodeCoverage, bool $disableCodeCoverageIgnore, bool $disableCoverageTargeting, bool $failOnAllIssues, bool $failOnDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnPhpunitDeprecation, bool $doNotFailOnPhpunitNotice, bool $doNotFailOnPhpunitWarning, bool $doNotFailOnEmptyTestSuite, bool $doNotFailOnIncomplete, bool $doNotFailOnNotice, bool $doNotFailOnRisky, bool $doNotFailOnSkipped, bool $doNotFailOnWarning, int $stopOnDefect, int $stopOnDeprecation, ?string $specificDeprecationToStopOn, int $stopOnError, int $stopOnFailure, int $stopOnIncomplete, int $stopOnNotice, int $stopOnRisky, int $stopOnSkipped, int $stopOnWarning, bool $outputToStandardErrorStream, int $columns, bool $noExtensions, ?string $pharExtensionDirectory, array $extensionBootstrappers, bool $backupGlobals, bool $backupStaticProperties, bool $beStrictAboutChangesToGlobalState, bool $colors, bool $processIsolation, bool $enforceTimeLimit, int $defaultTimeLimit, int $diffContext, int $timeoutForSmallTests, int $timeoutForMediumTests, int $timeoutForLargeTests, bool $reportUselessTests, bool $strictCoverage, bool $requireCoverageContribution, bool $disallowTestOutput, bool $displayDetailsOnAllIssues, bool $displayDetailsOnIncompleteTests, bool $displayDetailsOnSkippedTests, bool $displayDetailsOnTestsThatTriggerDeprecations, bool $displayDetailsOnPhpunitDeprecations, bool $displayDetailsOnPhpunitNotices, bool $displayDetailsOnTestsThatTriggerErrors, bool $displayDetailsOnTestsThatTriggerNotices, bool $displayDetailsOnTestsThatTriggerWarnings, bool $reverseDefectList, bool $requireCoverageMetadata, bool $requireSealedMockObjects, bool $noProgress, bool $noResults, bool $noOutput, int $executionOrder, int $executionOrderDefects, bool $resolveDependencies, ?string $logfileTeamcity, ?string $logfileJunit, ?string $logfileOtr, bool $includeGitInformation, bool $includeGitInformationInOtrLogfile, ?string $logfileTestdoxHtml, ?string $logfileTestdoxText, ?string $logEventsText, ?string $logEventsVerboseText, bool $compactOutput, bool $teamCityOutput, bool $testDoxOutput, bool $testDoxOutputSummary, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, ?string $filter, ?string $excludeFilter, ?string $testIdFilterFile, ?string $testIdFilter, array $groups, array $excludeGroups, int $randomOrderSeed, int $repeat, int $retry, bool $includeUncoveredFiles, TestSuiteCollection $testSuite, string $includeTestSuite, string $excludeTestSuite, ?string $defaultTestSuite, bool $ignoreTestSelectionInXmlConfiguration, array $testSuffixes, Php $php, bool $controlGarbageCollector, int $numberOfTestsBeforeGarbageCollection, ?string $generateBaseline, bool $debug, bool $withTelemetry, int $shortenArraysForExportThreshold) { $this->cliArguments = $cliArguments; $this->testFilesFile = $testFilesFile; @@ -600,6 +601,7 @@ public function __construct(array $cliArguments, ?string $testFilesFile, ?string $this->coverageXmlIncludeSource = $coverageXmlIncludeSource; $this->pathCoverage = $pathCoverage; $this->branchCoverage = $branchCoverage; + $this->coverageDriver = $coverageDriver; $this->ignoreDeprecatedCodeUnitsFromCodeCoverage = $ignoreDeprecatedCodeUnitsFromCodeCoverage; $this->disableCodeCoverageIgnore = $disableCodeCoverageIgnore; $this->disableCoverageTargeting = $disableCoverageTargeting; @@ -890,6 +892,26 @@ public function branchCoverage(): bool return $this->branchCoverage; } + /** + * @phpstan-assert-if-true !null $this->coverageDriver + */ + public function hasCoverageDriver(): bool + { + return $this->coverageDriver !== null; + } + + /** + * @throws CodeCoverageDriverNotConfiguredException + */ + public function coverageDriver(): string + { + if (!$this->hasCoverageDriver()) { + throw new CodeCoverageDriverNotConfiguredException; + } + + return $this->coverageDriver; + } + public function hasCoverageReport(): bool { return $this->hasCoverageClover() || diff --git a/src/TextUI/Configuration/Exception/CodeCoverageDriverNotConfiguredException.php b/src/TextUI/Configuration/Exception/CodeCoverageDriverNotConfiguredException.php new file mode 100644 index 00000000000..936a266b1c4 --- /dev/null +++ b/src/TextUI/Configuration/Exception/CodeCoverageDriverNotConfiguredException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TextUI\Configuration; + +use RuntimeException; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class CodeCoverageDriverNotConfiguredException extends RuntimeException implements Exception +{ +} diff --git a/src/TextUI/Configuration/Merger.php b/src/TextUI/Configuration/Merger.php index 63ad74d8234..bbb0f308a95 100644 --- a/src/TextUI/Configuration/Merger.php +++ b/src/TextUI/Configuration/Merger.php @@ -413,6 +413,12 @@ public function merge(CliConfiguration $cliConfiguration, XmlConfiguration $xmlC $branchCoverage = $xmlConfiguration->codeCoverage()->branchCoverage(); } + $coverageDriver = null; + + if ($xmlConfiguration->codeCoverage()->hasDriver()) { + $coverageDriver = $xmlConfiguration->codeCoverage()->driver(); + } + $defaultColors = Colors::default(); $defaultThresholds = Thresholds::default(); @@ -1200,6 +1206,7 @@ public function merge(CliConfiguration $cliConfiguration, XmlConfiguration $xmlC $coverageXmlIncludeSource, $pathCoverage, $branchCoverage, + $coverageDriver, $xmlConfiguration->codeCoverage()->ignoreDeprecatedCodeUnits(), $disableCodeCoverageIgnore, $disableCoverageTargeting, diff --git a/src/TextUI/Configuration/Xml/CodeCoverage/CodeCoverage.php b/src/TextUI/Configuration/Xml/CodeCoverage/CodeCoverage.php index 32f7943632a..35e6ecdffac 100644 --- a/src/TextUI/Configuration/Xml/CodeCoverage/CodeCoverage.php +++ b/src/TextUI/Configuration/Xml/CodeCoverage/CodeCoverage.php @@ -28,6 +28,7 @@ */ final readonly class CodeCoverage { + private ?string $driver; private bool $pathCoverage; private bool $branchCoverage; private bool $includeUncoveredFiles; @@ -42,8 +43,9 @@ private ?Text $text; private ?Xml $xml; - public function __construct(bool $pathCoverage, bool $branchCoverage, bool $includeUncoveredFiles, bool $ignoreDeprecatedCodeUnits, bool $disableCodeCoverageIgnore, ?Clover $clover, ?Cobertura $cobertura, ?Crap4j $crap4j, ?Html $html, ?OpenClover $openClover, ?Php $php, ?Text $text, ?Xml $xml) + public function __construct(?string $driver, bool $pathCoverage, bool $branchCoverage, bool $includeUncoveredFiles, bool $ignoreDeprecatedCodeUnits, bool $disableCodeCoverageIgnore, ?Clover $clover, ?Cobertura $cobertura, ?Crap4j $crap4j, ?Html $html, ?OpenClover $openClover, ?Php $php, ?Text $text, ?Xml $xml) { + $this->driver = $driver; $this->pathCoverage = $pathCoverage; $this->branchCoverage = $branchCoverage; $this->includeUncoveredFiles = $includeUncoveredFiles; @@ -59,6 +61,28 @@ public function __construct(bool $pathCoverage, bool $branchCoverage, bool $incl $this->xml = $xml; } + /** + * @phpstan-assert-if-true !null $this->driver + */ + public function hasDriver(): bool + { + return $this->driver !== null; + } + + /** + * @throws Exception + */ + public function driver(): string + { + if (!$this->hasDriver()) { + throw new Exception( + 'Code Coverage driver has not been configured', + ); + } + + return $this->driver; + } + public function pathCoverage(): bool { return $this->pathCoverage; diff --git a/src/TextUI/Configuration/Xml/DefaultConfiguration.php b/src/TextUI/Configuration/Xml/DefaultConfiguration.php index cfa66aec381..c1d8d76d08b 100644 --- a/src/TextUI/Configuration/Xml/DefaultConfiguration.php +++ b/src/TextUI/Configuration/Xml/DefaultConfiguration.php @@ -64,6 +64,7 @@ public static function create(): self true, ), new CodeCoverage( + null, false, false, true, diff --git a/src/TextUI/Configuration/Xml/Loader.php b/src/TextUI/Configuration/Xml/Loader.php index 0b4f464d195..640fc437e74 100644 --- a/src/TextUI/Configuration/Xml/Loader.php +++ b/src/TextUI/Configuration/Xml/Loader.php @@ -453,6 +453,7 @@ private function source(string $filename, DOMXPath $xpath): Source private function codeCoverage(string $filename, DOMXPath $xpath): CodeCoverage { + $driver = null; $pathCoverage = false; $branchCoverage = false; $includeUncoveredFiles = true; @@ -462,6 +463,8 @@ private function codeCoverage(string $filename, DOMXPath $xpath): CodeCoverage $element = $this->element($xpath, 'coverage'); if ($element !== null) { + $driver = $this->parseStringAttribute($element, 'driver'); + $pathCoverage = $this->parseBooleanAttribute( $element, 'pathCoverage', @@ -639,6 +642,7 @@ private function codeCoverage(string $filename, DOMXPath $xpath): CodeCoverage } return new CodeCoverage( + $driver, $pathCoverage, $branchCoverage, $includeUncoveredFiles, diff --git a/tests/_files/configuration_codecoverage_driver.xml b/tests/_files/configuration_codecoverage_driver.xml new file mode 100644 index 00000000000..d3f162c160e --- /dev/null +++ b/tests/_files/configuration_codecoverage_driver.xml @@ -0,0 +1,15 @@ + + + + + /path/to/files + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-exist.xml b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-exist.xml new file mode 100644 index 00000000000..42cdf4822b3 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-exist.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-extend-driver.xml b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-extend-driver.xml new file mode 100644 index 00000000000..437d998bf94 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-does-not-extend-driver.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-is-not-instantiable.xml b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-is-not-instantiable.xml new file mode 100644 index 00000000000..10a891f1def --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-class-is-not-instantiable.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-no-required-constructor-arguments.xml b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-no-required-constructor-arguments.xml new file mode 100644 index 00000000000..f53d9348fab --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-no-required-constructor-arguments.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-required-constructor-arguments.xml b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-required-constructor-arguments.xml new file mode 100644 index 00000000000..d857e843ae0 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-required-constructor-arguments.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/AbstractCustomDriver.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/AbstractCustomDriver.php new file mode 100644 index 00000000000..9811fccf928 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/AbstractCustomDriver.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +use SebastianBergmann\CodeCoverage\Driver\Driver; + +abstract class AbstractCustomDriver extends Driver +{ +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriver.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriver.php new file mode 100644 index 00000000000..772cc43aaeb --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriver.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; +use SebastianBergmann\CodeCoverage\Driver\Driver; + +final class CustomDriver extends Driver +{ + public function name(): string + { + return 'CustomDriver'; + } + + public function version(): string + { + return '1.0.0'; + } + + public function start(): void + { + } + + public function stop(): RawCodeCoverageData + { + return RawCodeCoverageData::fromXdebugWithoutPathCoverage([]); + } +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFilter.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFilter.php new file mode 100644 index 00000000000..7416459e0a5 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFilter.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; +use SebastianBergmann\CodeCoverage\Driver\Driver; +use SebastianBergmann\CodeCoverage\Filter; + +final class CustomDriverWithFilter extends Driver +{ + private readonly Filter $filter; + + public function __construct(Filter $filter) + { + $this->filter = $filter; + } + + public function name(): string + { + return 'CustomDriverWithFilter'; + } + + public function version(): string + { + return '1.0.0'; + } + + public function start(): void + { + } + + public function stop(): RawCodeCoverageData + { + return RawCodeCoverageData::fromXdebugWithoutPathCoverage([]); + } +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/Foo.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/Foo.php new file mode 100644 index 00000000000..e89566876d9 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/Foo.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +final class Foo +{ + public function value(): int + { + return 1; + } +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/NotADriver.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/NotADriver.php new file mode 100644 index 00000000000..b8b8c31cd83 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/NotADriver.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +final class NotADriver +{ +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/tests/FooTest.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/tests/FooTest.php new file mode 100644 index 00000000000..0c94878f80e --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/tests/FooTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +use PHPUnit\Framework\TestCase; + +final class FooTest extends TestCase +{ + public function testValue(): void + { + $this->assertSame(1, (new Foo)->value()); + } +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php new file mode 100644 index 00000000000..6f191f3d621 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php @@ -0,0 +1,7 @@ +run($_SERVER['argv']); +--CLEAN-- +run($_SERVER['argv']); +--CLEAN-- +run($_SERVER['argv']); +--CLEAN-- +run($_SERVER['argv']); +--CLEAN-- +run($_SERVER['argv']); +--CLEAN-- +coverageCacheDirectory(); } + public function testCoverageDriverThrowsWhenNotConfigured(): void + { + $configuration = $this->defaultConfiguration(); + + $this->assertFalse($configuration->hasCoverageDriver()); + + $this->expectException(CodeCoverageDriverNotConfiguredException::class); + + $configuration->coverageDriver(); + } + + public function testReturnsConfiguredCoverageDriver(): void + { + $configuration = $this->configurationFromXml('configuration_codecoverage_driver.xml'); + + $this->assertTrue($configuration->hasCoverageDriver()); + $this->assertSame('My\Custom\Driver', $configuration->coverageDriver()); + } + public function testCoverageCloverThrowsWhenNotConfigured(): void { $configuration = $this->defaultConfiguration(); diff --git a/tests/unit/TextUI/Configuration/MergerTest.php b/tests/unit/TextUI/Configuration/MergerTest.php index e48db8f6540..5f9dcd20de1 100644 --- a/tests/unit/TextUI/Configuration/MergerTest.php +++ b/tests/unit/TextUI/Configuration/MergerTest.php @@ -90,6 +90,18 @@ public function testCoverageTargetingIsNotDisabledByDefault(): void $this->assertFalse($mergedConfig->disableCoverageTargeting()); } + public function testCoverageDriverIsCarriedOverFromXmlConfiguration(): void + { + $fromFile = (new Loader)->load(TEST_FILES_PATH . 'configuration_codecoverage_driver.xml'); + + $fromCli = (new Builder)->fromParameters([]); + + $mergedConfig = (new Merger)->merge($fromCli, $fromFile); + + $this->assertTrue($mergedConfig->hasCoverageDriver()); + $this->assertSame('My\Custom\Driver', $mergedConfig->coverageDriver()); + } + public function testNoCoverageShouldOnlyAffectXmlConfiguration(): void { $phpCoverage = uniqid('php_coverage_'); diff --git a/tests/unit/TextUI/Configuration/Xml/LoaderTest.php b/tests/unit/TextUI/Configuration/Xml/LoaderTest.php index da1cc6e0a8a..343883df714 100644 --- a/tests/unit/TextUI/Configuration/Xml/LoaderTest.php +++ b/tests/unit/TextUI/Configuration/Xml/LoaderTest.php @@ -265,6 +265,7 @@ public function testCodeCoverageConfigurationIsReadCorrectly(): void { $codeCoverage = $this->configuration('configuration_codecoverage.xml')->codeCoverage(); + $this->assertFalse($codeCoverage->hasDriver()); $this->assertTrue($codeCoverage->pathCoverage()); $this->assertTrue($codeCoverage->includeUncoveredFiles()); $this->assertTrue($codeCoverage->ignoreDeprecatedCodeUnits()); @@ -321,6 +322,26 @@ public function testCodeCoverageConfigurationIsReadCorrectly(): void $this->assertSame(TEST_FILES_PATH . 'coverage', $codeCoverage->xml()->target()->path()); } + public function testCodeCoverageDriverConfigurationIsReadCorrectly(): void + { + $codeCoverage = $this->configuration('configuration_codecoverage_driver.xml')->codeCoverage(); + + $this->assertTrue($codeCoverage->hasDriver()); + $this->assertSame('My\Custom\Driver', $codeCoverage->driver()); + } + + public function testCodeCoverageDriverAccessorThrowsWhenNotConfigured(): void + { + $codeCoverage = $this->configuration('configuration_codecoverage.xml')->codeCoverage(); + + $this->assertFalse($codeCoverage->hasDriver()); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Code Coverage driver has not been configured'); + + $codeCoverage->driver(); + } + public function testGroupConfigurationIsReadCorrectly(): void { $groups = $this->configuration('configuration.xml')->groups(); From 5569758281b5c13e10942f9d7ccea958caeb16ed Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 7 Jun 2026 08:58:57 +0200 Subject: [PATCH 2/3] Improve test --- .../phpunit-with-fake-data.xml | 23 ++++++++++ .../src/CustomDriverWithFakeData.php | 42 +++++++++++++++++++ .../code-coverage-driver/vendor/autoload.php | 1 + .../code-coverage-driver-with-fake-data.phpt | 39 +++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-fake-data.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFakeData.php create mode 100644 tests/end-to-end/code-coverage/code-coverage-driver-with-fake-data.phpt diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-fake-data.xml b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-fake-data.xml new file mode 100644 index 00000000000..ab19f36f07b --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-fake-data.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src/Foo.php + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFakeData.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFakeData.php new file mode 100644 index 00000000000..17ca9672c80 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithFakeData.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +use function realpath; +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; +use SebastianBergmann\CodeCoverage\Driver\Driver; + +final class CustomDriverWithFakeData extends Driver +{ + public function name(): string + { + return 'CustomDriverWithFakeData'; + } + + public function version(): string + { + return '1.0.0'; + } + + public function start(): void + { + } + + public function stop(): RawCodeCoverageData + { + return RawCodeCoverageData::fromXdebugWithoutPathCoverage( + [ + realpath(__DIR__ . '/Foo.php') => [ + 16 => Driver::LINE_EXECUTED, + ], + ], + ); + } +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php index 6f191f3d621..16397fe3e33 100644 --- a/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php @@ -5,3 +5,4 @@ require __DIR__ . '/../src/AbstractCustomDriver.php'; require __DIR__ . '/../src/CustomDriver.php'; require __DIR__ . '/../src/CustomDriverWithFilter.php'; +require __DIR__ . '/../src/CustomDriverWithFakeData.php'; diff --git a/tests/end-to-end/code-coverage/code-coverage-driver-with-fake-data.phpt b/tests/end-to-end/code-coverage/code-coverage-driver-with-fake-data.phpt new file mode 100644 index 00000000000..33beaf16947 --- /dev/null +++ b/tests/end-to-end/code-coverage/code-coverage-driver-with-fake-data.phpt @@ -0,0 +1,39 @@ +--TEST-- +A custom code coverage driver provides coverage data that is used for the code coverage report +--FILE-- +run($_SERVER['argv']); +--CLEAN-- + Date: Sun, 7 Jun 2026 14:20:24 +0200 Subject: [PATCH 3/3] Allow branch coverage to be collected without path coverage --- src/Runner/CodeCoverage.php | 8 --- .../phpunit-with-branch-coverage.xml | 24 +++++++ .../src/CustomDriverWithBranchCoverage.php | 70 +++++++++++++++++++ .../code-coverage-driver/vendor/autoload.php | 1 + ...-coverage-driver-with-branch-coverage.phpt | 40 +++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-branch-coverage.xml create mode 100644 tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithBranchCoverage.php create mode 100644 tests/end-to-end/code-coverage/code-coverage-driver-with-branch-coverage.phpt diff --git a/src/Runner/CodeCoverage.php b/src/Runner/CodeCoverage.php index 24c4a348af0..9a0ad32db1b 100644 --- a/src/Runner/CodeCoverage.php +++ b/src/Runner/CodeCoverage.php @@ -532,15 +532,7 @@ private function activate(Filter $filter, bool $branchCoverage, bool $pathCovera } if ($pathCoverage) { - $granularity = Granularity::LineBranchAndPath; - } - - /** - * @todo This needs to be removed once code coverage drivers are supported that can collect branch coverage without path coverage - */ - if ($branchCoverage || $pathCoverage) { $branchCoverage = true; - $pathCoverage = true; $granularity = Granularity::LineBranchAndPath; } diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-branch-coverage.xml b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-branch-coverage.xml new file mode 100644 index 00000000000..c6a40928b2c --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/phpunit-with-branch-coverage.xml @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src/Foo.php + + + + + + + + + diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithBranchCoverage.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithBranchCoverage.php new file mode 100644 index 00000000000..15b91b19070 --- /dev/null +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/src/CustomDriverWithBranchCoverage.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\CodeCoverageDriver; + +use function realpath; +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; +use SebastianBergmann\CodeCoverage\Driver\Driver; + +/** + * A driver that can collect branch coverage but not path coverage. + */ +final class CustomDriverWithBranchCoverage extends Driver +{ + public function name(): string + { + return 'CustomDriverWithBranchCoverage'; + } + + public function version(): string + { + return '1.0.0'; + } + + public function start(): void + { + } + + public function stop(): RawCodeCoverageData + { + $file = realpath(__DIR__ . '/Foo.php'); + + return RawCodeCoverageData::fromLineAndBranchCoverage( + [ + $file => [ + 16 => Driver::LINE_EXECUTED, + ], + ], + [ + $file => [ + 'PHPUnit\TestFixture\CodeCoverageDriver\Foo->value' => [ + 'branches' => [ + 0 => [ + 'op_start' => 0, + 'op_end' => 1, + 'line_start' => 14, + 'line_end' => 16, + 'hit' => Driver::BRANCH_HIT, + 'out' => [], + 'out_hit' => [], + ], + ], + 'paths' => [], + ], + ], + ], + ); + } + + protected function canCollectBranchCoverage(): bool + { + return true; + } +} diff --git a/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php b/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php index 16397fe3e33..d9f42b5b801 100644 --- a/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php +++ b/tests/end-to-end/code-coverage/_files/code-coverage-driver/vendor/autoload.php @@ -6,3 +6,4 @@ require __DIR__ . '/../src/CustomDriver.php'; require __DIR__ . '/../src/CustomDriverWithFilter.php'; require __DIR__ . '/../src/CustomDriverWithFakeData.php'; +require __DIR__ . '/../src/CustomDriverWithBranchCoverage.php'; diff --git a/tests/end-to-end/code-coverage/code-coverage-driver-with-branch-coverage.phpt b/tests/end-to-end/code-coverage/code-coverage-driver-with-branch-coverage.phpt new file mode 100644 index 00000000000..a2f2601ad6f --- /dev/null +++ b/tests/end-to-end/code-coverage/code-coverage-driver-with-branch-coverage.phpt @@ -0,0 +1,40 @@ +--TEST-- +A custom code coverage driver that supports branch coverage but not path coverage can be used when only branch coverage is requested +--FILE-- +run($_SERVER['argv']); +--CLEAN-- +