├── phpstan.baseline-8.2.neon ├── .gitignore ├── tests ├── Subscriber │ └── Application │ │ ├── min-coverage-rules-empty.php │ │ ├── min-coverage-invalid-rule-instances.php │ │ ├── min-coverage-rules-no-tracked-lines.php │ │ ├── min-coverage-rules-total-only.php │ │ ├── min-coverage-rules-without-total.php │ │ ├── min-coverage-rules-with-warning.php │ │ ├── min-coverage-rules-invalid.php │ │ ├── min-coverage-rules-success.php │ │ ├── min-coverage-rules-with-duplicates.php │ │ ├── min-coverage-rules-no-exit.php │ │ ├── min-coverage-rules-with-failed-rule.php │ │ └── ApplicationFinishedSubscriberTest.php ├── clover-invalid.xml ├── clover-test-divide-by-zero.xml ├── PausedTimer.php ├── clover-with-no-tracked-lines.xml ├── SpyOutput.php ├── FixedResourceUsageFormatter.php └── clover.xml ├── readme ├── fail-example.png ├── success-example.png └── warning-example.png ├── phpstan.neon ├── docker └── php-cli │ ├── xdebug.ini │ └── Dockerfile ├── src ├── Exitter.php ├── Timer │ ├── Timer.php │ ├── ResourceUsageFormatter.php │ ├── SystemTimer.php │ └── SystemResourceUsageFormatter.php ├── PhpUnitExtension.php ├── MinCoverage │ ├── MinCoverageRule.php │ ├── ResultStatus.php │ ├── MinCoverageRules.php │ ├── CoverageMetric.php │ └── MinCoverageResult.php ├── ConsoleOutput.php └── Subscriber │ └── Application │ └── ApplicationFinishedSubscriber.php ├── docker-compose.yml ├── .php-cs-fixer.dist.php ├── phpstan.ignore-by-php-version.neon.php ├── phpunit.xml.dist ├── Makefile ├── LICENSE ├── composer.json ├── .github └── workflows │ └── ci.yml └── README.md /phpstan.baseline-8.2.neon: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .phpunit.cache 3 | .php-cs-fixer.cache -------------------------------------------------------------------------------- /tests/Subscriber/Application/min-coverage-rules-empty.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docker/php-cli/xdebug.ini: -------------------------------------------------------------------------------- 1 | xdebug.mode=coverage,debug 2 | xdebug.start_with_request=yes 3 | xdebug.discover_client_host=0 4 | xdebug.client_host=host.docker.internal 5 | xdebug.idekey=PHPSTORM 6 | -------------------------------------------------------------------------------- /docker/php-cli/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.4-cli 2 | 3 | RUN apt-get update && apt-get install -y zip unzip git curl 4 | RUN docker-php-ext-install mysqli pdo pdo_mysql 5 | 6 | COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer -------------------------------------------------------------------------------- /src/Exitter.php: -------------------------------------------------------------------------------- 1 | 100, 8 | ]; 9 | -------------------------------------------------------------------------------- /src/Timer/Timer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ->exclude('vendor') 7 | ->exclude('docker') 8 | ->exclude('bin'); 9 | 10 | return (new PhpCsFixer\Config) 11 | ->setRules([ 12 | '@Symfony' => true 13 | ]) 14 | ->setFinder($finder); -------------------------------------------------------------------------------- /phpstan.ignore-by-php-version.neon.php: -------------------------------------------------------------------------------- 1 | = 80200) { 7 | $includes[] = __DIR__.'/phpstan.baseline-8.2.neon'; 8 | } 9 | 10 | $config = []; 11 | $config['includes'] = $includes; 12 | $config['parameters']['phpVersion'] = PHP_VERSION_ID; 13 | 14 | return $config; 15 | -------------------------------------------------------------------------------- /src/Timer/ResourceUsageFormatter.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/Subscriber/Application/min-coverage-rules-with-warning.php: -------------------------------------------------------------------------------- 1 | duration; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | src 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Timer/SystemTimer.php: -------------------------------------------------------------------------------- 1 | timer->start(); 26 | } 27 | 28 | /** 29 | * @codeCoverageIgnore 30 | */ 31 | public function stop(): Duration 32 | { 33 | return $this->timer->stop(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/clover-with-no-tracked-lines.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/Subscriber/Application/min-coverage-rules-no-exit.php: -------------------------------------------------------------------------------- 1 | messages = [...$this->messages, ...$messages]; 17 | } 18 | 19 | public function write($messages, bool $newline = false, int $options = self::OUTPUT_NORMAL): void 20 | { 21 | if (!is_iterable($messages)) { 22 | $messages = [$messages]; 23 | } 24 | $this->messages = [...$this->messages, ...$messages]; 25 | } 26 | 27 | public function __toString(): string 28 | { 29 | return implode(PHP_EOL, $this->messages); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/FixedResourceUsageFormatter.php: -------------------------------------------------------------------------------- 1 | asString(), 25 | number_format($this->usageInMb, 2, '.', ''), 26 | ); 27 | } 28 | 29 | public function resourceUsageSinceStartOfRequest(): string 30 | { 31 | return sprintf( 32 | 'Time: 00:00.350, Memory: %s MB', 33 | number_format($this->usageInMb, 2, '.', ''), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/PhpUnitExtension.php: -------------------------------------------------------------------------------- 1 | registerSubscribers( 30 | $subscriber, 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Timer/SystemResourceUsageFormatter.php: -------------------------------------------------------------------------------- 1 | resourceUsageFormatter->resourceUsage($duration); 26 | } 27 | 28 | /** 29 | * @codeCoverageIgnore 30 | */ 31 | public function resourceUsageSinceStartOfRequest(): string 32 | { 33 | return $this->resourceUsageFormatter->resourceUsageSinceStartOfRequest(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/MinCoverage/MinCoverageRule.php: -------------------------------------------------------------------------------- 1 | minCoverage < 0 || $this->minCoverage > 100) { 15 | throw new \RuntimeException(sprintf('MinCoverage has to be value between 0 and 100. %s given', $this->minCoverage)); 16 | } 17 | } 18 | 19 | public function getPattern(): string 20 | { 21 | return $this->pattern; 22 | } 23 | 24 | public function getMinCoverage(): int 25 | { 26 | return $this->minCoverage; 27 | } 28 | 29 | public function exitOnLowCoverage(): bool 30 | { 31 | return $this->exitOnLowCoverage; 32 | } 33 | 34 | public function isTotalRule(): bool 35 | { 36 | return MinCoverageRule::TOTAL === $this->getPattern(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Robin Ingelbrecht 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "robiningelbrecht/phpunit-coverage-tools", 3 | "description": "PHPUnit coverage tools", 4 | "keywords": [ 5 | "Testing", 6 | "PHP", 7 | "Code coverage", 8 | "phpunit" 9 | ], 10 | "type": "library", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Robin Ingelbrecht", 15 | "email": "ingelbrecht_robin@hotmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "ext-simplexml": "*", 21 | "ext-xmlreader": "*", 22 | "phpunit/phpunit": "^10.3||^11.0||^12.0", 23 | "symfony/console": "^5.4||^6.2||^7.0||^8.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "RobinIngelbrecht\\PHPUnitCoverageTools\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Tests\\": "tests/" 33 | } 34 | }, 35 | "require-dev": { 36 | "friendsofphp/php-cs-fixer": "^3.9", 37 | "phpstan/phpstan": "^2", 38 | "spatie/phpunit-snapshot-assertions": "^5.0" 39 | }, 40 | "config": { 41 | "sort-packages": true 42 | }, 43 | "scripts": { 44 | "lint:fix": " ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php", 45 | "phpunit:test": "vendor/bin/phpunit --configuration=tests/phpunit.test.xml --no-output" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/MinCoverage/ResultStatus.php: -------------------------------------------------------------------------------- 1 | self::SUCCESS , 15 | 2 => self::WARNING, 16 | 3 => self::FAILED, 17 | default => throw new \InvalidArgumentException('Invalid weight '.$weight), 18 | }; 19 | } 20 | 21 | public function getWeight(): int 22 | { 23 | return match ($this) { 24 | self::SUCCESS => 1, 25 | self::WARNING => 2, 26 | self::FAILED => 3, 27 | }; 28 | } 29 | 30 | public function getMessage(): string 31 | { 32 | return match ($this) { 33 | self::SUCCESS => 'All minimum code coverage rules passed, give yourself a pat on the back!', 34 | self::WARNING => 'There was at least one pattern that did not match any covered classes. Please consider removing them.', 35 | self::FAILED => 'Not all minimum code coverage rules passed, please try again... :)', 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | jobs: 6 | test-suite: 7 | name: PHPStan, PHPcs & Testsuite 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | php-versions: [ '8.1', '8.2', '8.3', '8.4', '8.5' ] 12 | 13 | steps: 14 | # https://github.com/marketplace/actions/setup-php-action 15 | - name: Setup PHP ${{ matrix.php-versions }} with Xdebug 3.x 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: ${{ matrix.php-versions }} 19 | coverage: xdebug 20 | 21 | # https://github.com/marketplace/actions/checkout 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | 25 | - name: Remove composer.lock 26 | run: | 27 | rm composer.lock 28 | 29 | - name: Install dependencies 30 | run: composer install --prefer-dist 31 | 32 | - name: Run PHPStan 33 | run: vendor/bin/phpstan analyse 34 | 35 | #- name: Run PHPcs fixer dry-run 36 | # run: vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --config=.php-cs-fixer.dist.php 37 | 38 | - name: Run test suite 39 | run: vendor/bin/phpunit --fail-on-incomplete --log-junit junit.xml --coverage-clover clover.xml 40 | 41 | # https://github.com/marketplace/actions/codecov 42 | - name: Send test coverage to codecov.io 43 | uses: codecov/codecov-action@v5 44 | with: 45 | files: clover.xml,!tests/clover.xml,!tests/clover-invalid.xml,!tests/clover-test-divide-by-zero.xml 46 | fail_ci_if_error: true # optional (default = false) 47 | verbose: true # optional (default = false) 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | -------------------------------------------------------------------------------- /src/MinCoverage/MinCoverageRules.php: -------------------------------------------------------------------------------- 1 | rules; 24 | } 25 | 26 | public function hasTotalRule(): bool 27 | { 28 | foreach ($this->rules as $rule) { 29 | if ($rule->isTotalRule()) { 30 | return true; 31 | } 32 | } 33 | 34 | return false; 35 | } 36 | 37 | public function hasOtherRulesThanTotalRule(): bool 38 | { 39 | foreach ($this->rules as $rule) { 40 | if (!$rule->isTotalRule()) { 41 | return true; 42 | } 43 | } 44 | 45 | return false; 46 | } 47 | 48 | public static function fromInt(int $minCoverage, bool $exitOnLowCoverage): self 49 | { 50 | return new self( 51 | [new MinCoverageRule( 52 | pattern: MinCoverageRule::TOTAL, 53 | minCoverage: $minCoverage, 54 | exitOnLowCoverage: $exitOnLowCoverage 55 | )], 56 | ); 57 | } 58 | 59 | public static function fromConfigFile(string $filePathToConfigFile): self 60 | { 61 | /** @var string $reflectionFileName */ 62 | $reflectionFileName = (new \ReflectionClass(ClassLoader::class))->getFileName(); 63 | $absolutePathToConfigFile = dirname($reflectionFileName, 3).'/'.$filePathToConfigFile; 64 | 65 | if (!file_exists($absolutePathToConfigFile)) { 66 | throw new \RuntimeException(sprintf('Config file %s not found', $absolutePathToConfigFile)); 67 | } 68 | 69 | $rules = require $absolutePathToConfigFile; 70 | foreach ($rules as $minCoverageRule) { 71 | if (!$minCoverageRule instanceof MinCoverageRule) { 72 | throw new \RuntimeException('Make sure all coverage rules are of instance '.MinCoverageRule::class); 73 | } 74 | } 75 | $patterns = array_map(fn (MinCoverageRule $minCoverageRule) => $minCoverageRule->getPattern(), $rules); 76 | if (count(array_unique($patterns)) !== count($patterns)) { 77 | throw new \RuntimeException('Make sure all coverage rule patterns are unique'); 78 | } 79 | 80 | return new self($rules); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/MinCoverage/CoverageMetric.php: -------------------------------------------------------------------------------- 1 | forClass; 23 | } 24 | 25 | public function getNumberOfMethods(): int 26 | { 27 | return $this->numberOfMethods; 28 | } 29 | 30 | public function getNumberOfCoveredMethods(): int 31 | { 32 | return $this->numberOfCoveredMethods; 33 | } 34 | 35 | public function getNumberOfStatements(): int 36 | { 37 | return $this->numberOfStatements; 38 | } 39 | 40 | public function getNumberOfCoveredStatements(): int 41 | { 42 | return $this->numberOfCoveredStatements; 43 | } 44 | 45 | public function getNumberOfConditionals(): int 46 | { 47 | return $this->numberOfConditionals; 48 | } 49 | 50 | public function getNumberOfCoveredConditionals(): int 51 | { 52 | return $this->numberOfCoveredConditionals; 53 | } 54 | 55 | public function getNumberOfTrackedLines(): int 56 | { 57 | return $this->numberOfTrackedLines; 58 | } 59 | 60 | public function getNumberOfCoveredLines(): int 61 | { 62 | return $this->numberOfCoveredLines; 63 | } 64 | 65 | public function getTotalPercentageCoverage(): float 66 | { 67 | // https://confluence.atlassian.com/clover/how-are-the-clover-coverage-percentages-calculated-79986990.html 68 | // TPC = (coveredconditionals + coveredstatements + coveredmethods) / (conditionals + statements + methods) 69 | $divideBy = $this->getNumberOfConditionals() + $this->getNumberOfStatements() + $this->getNumberOfMethods(); 70 | if (0 === $divideBy) { 71 | return 0.00; 72 | } 73 | 74 | return round((($this->getNumberOfCoveredConditionals() + $this->getNumberOfCoveredStatements() + $this->getNumberOfCoveredMethods()) / 75 | $divideBy) * 100, 2); 76 | } 77 | 78 | public static function fromCloverXmlNode(\SimpleXMLElement $node, string $forClass): self 79 | { 80 | /** @var \SimpleXMLElement $attributes */ 81 | $attributes = $node->attributes(); 82 | 83 | return new self( 84 | forClass: $forClass, 85 | numberOfMethods: (int) $attributes['methods'], 86 | numberOfCoveredMethods: (int) $attributes['coveredmethods'], 87 | numberOfStatements: (int) $attributes['statements'], 88 | numberOfCoveredStatements: (int) $attributes['coveredstatements'], 89 | numberOfConditionals: (int) $attributes['conditionals'], 90 | numberOfCoveredConditionals: (int) $attributes['coveredconditionals'], 91 | numberOfTrackedLines: (int) $attributes['elements'], 92 | numberOfCoveredLines: (int) $attributes['coveredelements'], 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/MinCoverage/MinCoverageResult.php: -------------------------------------------------------------------------------- 1 | pattern; 20 | } 21 | 22 | public function getStatus(): ResultStatus 23 | { 24 | if (0 === $this->getNumberOfTrackedLines()) { 25 | return ResultStatus::WARNING; 26 | } 27 | 28 | return $this->expectedMinCoverage <= $this->actualMinCoverage ? ResultStatus::SUCCESS : ResultStatus::FAILED; 29 | } 30 | 31 | public function getExpectedMinCoverage(): int 32 | { 33 | return $this->expectedMinCoverage; 34 | } 35 | 36 | public function getActualMinCoverage(): float 37 | { 38 | return $this->actualMinCoverage; 39 | } 40 | 41 | public function getNumberOfTrackedLines(): int 42 | { 43 | return $this->numberOfTrackedLines; 44 | } 45 | 46 | public function getNumberOfCoveredLines(): int 47 | { 48 | return $this->numberOfCoveredLines; 49 | } 50 | 51 | public function exitOnLowCoverage(): bool 52 | { 53 | return $this->exitOnLowCoverage; 54 | } 55 | 56 | /** 57 | * @param CoverageMetric[] $metrics 58 | * 59 | * @return MinCoverageResult[] 60 | */ 61 | public static function mapFromRulesAndMetrics( 62 | MinCoverageRules $minCoverageRules, 63 | array $metrics, 64 | ?CoverageMetric $metricTotal = null, 65 | ): array { 66 | $results = []; 67 | foreach ($minCoverageRules->getRules() as $minCoverageRule) { 68 | $pattern = $minCoverageRule->getPattern(); 69 | $minCoverage = $minCoverageRule->getMinCoverage(); 70 | if (MinCoverageRule::TOTAL === $minCoverageRule->getPattern() && $metricTotal) { 71 | $results[] = new MinCoverageResult( 72 | pattern: $pattern, 73 | expectedMinCoverage: $minCoverage, 74 | actualMinCoverage: $metricTotal->getTotalPercentageCoverage(), 75 | numberOfTrackedLines: $metricTotal->getNumberOfTrackedLines(), 76 | numberOfCoveredLines: $metricTotal->getNumberOfCoveredLines(), 77 | exitOnLowCoverage: $minCoverageRule->exitOnLowCoverage() 78 | ); 79 | continue; 80 | } 81 | 82 | $metricsForPattern = array_filter($metrics, fn (CoverageMetric $metric) => fnmatch($pattern, $metric->getForClass(), FNM_NOESCAPE)); 83 | $totalTrackedLines = array_sum(array_map(fn (CoverageMetric $metric) => $metric->getNumberOfTrackedLines(), $metricsForPattern)); 84 | $totalCoveredLines = array_sum(array_map(fn (CoverageMetric $metric) => $metric->getNumberOfCoveredLines(), $metricsForPattern)); 85 | 86 | $coveragePercentage = 0; 87 | foreach ($metricsForPattern as $metric) { 88 | if (0 === $totalTrackedLines) { 89 | continue; 90 | } 91 | $weight = $metric->getNumberOfTrackedLines() / $totalTrackedLines; 92 | $coveragePercentage += ($metric->getTotalPercentageCoverage() * $weight); 93 | } 94 | 95 | $results[] = new MinCoverageResult( 96 | pattern: $pattern, 97 | expectedMinCoverage: $minCoverage, 98 | actualMinCoverage: round($coveragePercentage, 2), 99 | numberOfTrackedLines: $totalTrackedLines, 100 | numberOfCoveredLines: $totalCoveredLines, 101 | exitOnLowCoverage: $minCoverageRule->exitOnLowCoverage() 102 | ); 103 | } 104 | 105 | uasort($results, function (MinCoverageResult $a, MinCoverageResult $b) { 106 | if (MinCoverageRule::TOTAL === $a->getPattern()) { 107 | return 1; 108 | } 109 | if ($a->getStatus() === $b->getStatus()) { 110 | return 0; 111 | } 112 | 113 | return ($a->getStatus()->getWeight() < $b->getStatus()->getWeight()) ? 1 : -1; 114 | }); 115 | 116 | return $results; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

PHPUnit Coverage tools

2 | 3 |

4 | CI 5 | License 6 | 7 | PHPStan Enabled 8 | PHP 9 | PHPUnit 10 | PHPUnit 11 |

12 | 13 | --- 14 | 15 | This extension allows you to enforce minimum code coverage by using the clover xml report from PHPUnit. 16 | Based on the given threshold the testsuite will exit ok if the coverage is higher than the threshold 17 | or exit with code 1 if the coverage is lower than the threshold. 18 | This can be used in your continuous deployment environment or can be added to a pre-commit hook. 19 | 20 | ## Installation 21 | 22 | ```bash 23 | > composer require robiningelbrecht/phpunit-coverage-tools --dev 24 | ``` 25 | 26 | ## Configuration 27 | 28 | Navigate to your `phpunit.xml.dist` file and add following config to set default options: 29 | 30 | ```xml 31 | 32 | 33 | 34 | 35 | 36 | 37 | ``` 38 | ## Usage 39 | 40 | Just run your testsuite like you normally would, but add following arguments: 41 | 42 | ### --min-coverage=`[INTEGER]` 43 | 44 | ```bash 45 | > vendor/bin/phpunit --coverage-clover=path/to/clover.xml -d --min-coverage=100 46 | ``` 47 | 48 | When assigning an integer between 0 - 100, you enforce a minimum code coverage 49 | for all your classes. In other words, the total coverage of your project has to be 50 | higher than this threshold. 51 | 52 | ### --min-coverage=`[path/to/min-coverage-rules.php]` 53 | 54 | ```bash 55 | > vendor/bin/phpunit --coverage-clover=path/to/clover.xml -d --min-coverage="path/to/min-coverage-rules.php" 56 | ``` 57 | 58 | When referencing a PHP config file, you can configure more complex rules. 59 | This allows you to be stricter for critical parts of your application and less strict 60 | for parts of your app that are not that critical. 61 | 62 | For example: 63 | 64 | ```php 65 | output->setDecorated(true); 25 | $this->output->getFormatter()->setStyle( 26 | 'success', 27 | new OutputFormatterStyle('green', null, ['bold']) 28 | ); 29 | $this->output->getFormatter()->setStyle( 30 | 'failed', 31 | new OutputFormatterStyle('red', null, ['bold']) 32 | ); 33 | $this->output->getFormatter()->setStyle( 34 | 'warning', 35 | new OutputFormatterStyle('yellow', null, ['bold']) 36 | ); 37 | $this->output->getFormatter()->setStyle( 38 | 'bold', 39 | new OutputFormatterStyle(null, null, ['bold']) 40 | ); 41 | } 42 | 43 | public static function create(): self 44 | { 45 | return new self( 46 | output: new \Symfony\Component\Console\Output\ConsoleOutput(), 47 | resourceUsageFormatter: SystemResourceUsageFormatter::create() 48 | ); 49 | } 50 | 51 | /** 52 | * @param MinCoverageResult[] $results 53 | */ 54 | public function print(array $results, Duration $duration): void 55 | { 56 | /** @var non-empty-array $statusWeights */ 57 | $statusWeights = array_map(fn (MinCoverageResult $result) => $result->getStatus()->getWeight(), $results); 58 | $finalStatus = ResultStatus::fromWeight(max($statusWeights)); 59 | 60 | $this->output->writeln(''); 61 | $tableStyle = new TableStyle(); 62 | $tableStyle 63 | ->setHeaderTitleFormat(' %s ') 64 | ->setCellHeaderFormat('%s') 65 | ->setPadType(STR_PAD_BOTH); 66 | 67 | $table = new Table($this->output); 68 | $table 69 | ->setStyle($tableStyle) 70 | ->setHeaderTitle('Code coverage results') 71 | ->setHeaders(['Pattern', 'Expected', 'Actual', '', 'Exit on fail?']) 72 | ->setColumnMaxWidth(1, 10) 73 | ->setColumnMaxWidth(2, 8) 74 | ->setColumnMaxWidth(4, 11) 75 | ->setRows([ 76 | ...array_map(fn (MinCoverageResult $result) => [ 77 | new TableCell( 78 | $result->getPattern(), 79 | [ 80 | 'style' => new TableCellStyle([ 81 | 'align' => 'left', 82 | ]), 83 | ] 84 | ), 85 | $result->getExpectedMinCoverage().'%', 86 | sprintf('<%s>%s%%', $result->getStatus()->value, $result->getActualMinCoverage(), $result->getStatus()->value), 87 | new TableCell( 88 | $result->getNumberOfTrackedLines() > 0 ? 89 | sprintf('%s of %s lines covered', $result->getNumberOfCoveredLines(), $result->getNumberOfTrackedLines()) : 90 | 'No lines to track...?', 91 | [ 92 | 'style' => new TableCellStyle([ 93 | 'align' => 'left', 94 | ]), 95 | ] 96 | ), 97 | $result->exitOnLowCoverage() ? 'Yes' : 'No', 98 | ], $results), 99 | new TableSeparator(), 100 | [ 101 | new TableCell( 102 | $finalStatus->getMessage(), 103 | [ 104 | 'colspan' => 5, 105 | 'style' => new TableCellStyle([ 106 | 'align' => 'center', 107 | 'cellFormat' => '<'.$finalStatus->value.'>%svalue.'>', 108 | ] 109 | ), 110 | ] 111 | ), 112 | ], 113 | ]); 114 | $table->render(); 115 | $this->output->writeln($this->resourceUsageFormatter->resourceUsage($duration)); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Subscriber/Application/ApplicationFinishedSubscriber.php: -------------------------------------------------------------------------------- 1 | timer->start(); 36 | /** @var string $reflectionFileName */ 37 | $reflectionFileName = (new \ReflectionClass(ClassLoader::class))->getFileName(); 38 | 39 | $absolutePathToCloverXml = $this->pathToCloverXml; 40 | if (!str_starts_with($this->pathToCloverXml, '/')) { 41 | // User is probably using relative path to clover.xml 42 | $absolutePathToCloverXml = dirname($reflectionFileName, 3).'/'.$this->pathToCloverXml; 43 | } 44 | 45 | if (!file_exists($absolutePathToCloverXml)) { 46 | throw new \RuntimeException('Clover XML file not found at: '.$absolutePathToCloverXml); 47 | } 48 | 49 | /** @var CoverageMetric[] $metrics */ 50 | $metrics = []; 51 | $metricTotal = null; 52 | 53 | // @TODO: Move this to static function in CoverageMetric? 54 | $reader = new \XMLReader(); 55 | $reader->open($absolutePathToCloverXml); 56 | while ($reader->read()) { 57 | if ($this->minCoverageRules->hasTotalRule() && \XMLReader::ELEMENT == $reader->nodeType && 'metrics' == $reader->name && 2 === $reader->depth) { 58 | /** @var \SimpleXMLElement $node */ 59 | $node = simplexml_load_string($reader->readOuterXml()); 60 | $metricTotal = CoverageMetric::fromCloverXmlNode( 61 | node: $node, 62 | forClass: MinCoverageRule::TOTAL 63 | ); 64 | continue; 65 | } 66 | if ($this->minCoverageRules->hasOtherRulesThanTotalRule() 67 | && \XMLReader::ELEMENT == $reader->nodeType && 'class' == $reader->name 68 | && (3 === $reader->depth || 4 === $reader->depth)) { 69 | /** @var \SimpleXMLElement $node */ 70 | $node = simplexml_load_string($reader->readInnerXml()); 71 | /** @var string $className */ 72 | $className = $reader->getAttribute('name'); 73 | $metrics[] = CoverageMetric::fromCloverXmlNode( 74 | node: $node, 75 | forClass: $className 76 | ); 77 | } 78 | } 79 | $reader->close(); 80 | 81 | if ($this->cleanUpCloverXml) { 82 | unlink($absolutePathToCloverXml); 83 | } 84 | 85 | if (!$metrics && !$metricTotal) { 86 | throw new \RuntimeException('Could not determine coverage metrics'); 87 | } 88 | 89 | $results = MinCoverageResult::mapFromRulesAndMetrics( 90 | minCoverageRules: $this->minCoverageRules, 91 | metrics: $metrics, 92 | metricTotal: $metricTotal, 93 | ); 94 | 95 | $this->consoleOutput->print( 96 | results: $results, 97 | duration: $this->timer->stop() 98 | ); 99 | 100 | $needsExit = !empty(array_filter( 101 | $results, 102 | fn (MinCoverageResult $minCoverageResult) => $minCoverageResult->exitOnLowCoverage() && ResultStatus::FAILED === $minCoverageResult->getStatus()) 103 | ); 104 | if ($needsExit) { 105 | $this->exitter->exit(); 106 | } 107 | } 108 | 109 | /** 110 | * @param string[] $args 111 | */ 112 | public static function fromConfigurationAndParameters( 113 | Configuration $configuration, 114 | ParameterCollection $parameters, 115 | array $args, 116 | ): ?self { 117 | if (!$configuration->hasCoverageClover()) { 118 | return null; 119 | } 120 | 121 | $rules = null; 122 | foreach ($args as $arg) { 123 | if (!str_starts_with($arg, '--min-coverage=')) { 124 | continue; 125 | } 126 | 127 | try { 128 | if (preg_match('/--min-coverage=(?[\d]+)/', $arg, $matches)) { 129 | $rules = MinCoverageRules::fromInt( 130 | minCoverage: (int) $matches['minCoverage'], 131 | exitOnLowCoverage: $parameters->has('exitOnLowCoverage') && (int) $parameters->get('exitOnLowCoverage') 132 | ); 133 | break; 134 | } 135 | 136 | if (preg_match('/--min-coverage=(?[\S]+)/', $arg, $matches)) { 137 | $rules = MinCoverageRules::fromConfigFile(trim($matches['minCoverage'], '"')); 138 | break; 139 | } 140 | } catch (\RuntimeException) { 141 | return null; 142 | } 143 | } 144 | 145 | if (empty($rules) || empty($rules->getRules())) { 146 | return null; 147 | } 148 | 149 | if (!$cleanUpCloverXml = in_array('--clean-up-clover-xml', $args, true)) { 150 | $cleanUpCloverXml = $parameters->has('cleanUpCloverXml') && (int) $parameters->get('cleanUpCloverXml'); 151 | } 152 | 153 | return new self( 154 | pathToCloverXml: $configuration->coverageClover(), 155 | minCoverageRules: $rules, 156 | cleanUpCloverXml: $cleanUpCloverXml, 157 | exitter: new Exitter(), 158 | consoleOutput: ConsoleOutput::create(), 159 | timer: SystemTimer::create(), 160 | ); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tests/clover.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /tests/Subscriber/Application/ApplicationFinishedSubscriberTest.php: -------------------------------------------------------------------------------- 1 | exitter = $this->createMock(Exitter::class); 42 | $this->output = new SpyOutput(); 43 | $this->timer = PausedTimer::withDuration(\SebastianBergmann\Timer\Duration::fromMicroseconds(350123)); 44 | $this->resourceUsageFormatter = FixedResourceUsageFormatter::withUsageInMb(12.00); 45 | } 46 | 47 | public function testNotifyWithAtLeastOneFailedRule(): void 48 | { 49 | $this->exitter 50 | ->expects($this->once()) 51 | ->method('exit'); 52 | 53 | $subscriber = new ApplicationFinishedSubscriber( 54 | pathToCloverXml: 'tests/clover.xml', 55 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-with-failed-rule.php'), 56 | cleanUpCloverXml: false, 57 | exitter: $this->exitter, 58 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 59 | timer: $this->timer, 60 | ); 61 | 62 | $subscriber->notify(event: new Finished( 63 | new Info( 64 | current: new Snapshot( 65 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 66 | memoryUsage: MemoryUsage::fromBytes(100), 67 | peakMemoryUsage: MemoryUsage::fromBytes(100), 68 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 69 | ), 70 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 71 | memorySinceStart: MemoryUsage::fromBytes(100), 72 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 73 | memorySincePrevious: MemoryUsage::fromBytes(100), 74 | ), 75 | 0 76 | )); 77 | 78 | $this->assertStringContainsString('Not all minimum code coverage rules', $this->output); 79 | $this->assertStringContainsString('passed, please try again... :)', $this->output); 80 | } 81 | 82 | public function testNotifyWithAWarning(): void 83 | { 84 | $this->exitter 85 | ->expects($this->never()) 86 | ->method('exit'); 87 | 88 | $subscriber = new ApplicationFinishedSubscriber( 89 | pathToCloverXml: 'tests/clover.xml', 90 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-with-warning.php'), 91 | cleanUpCloverXml: false, 92 | exitter: $this->exitter, 93 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 94 | timer: $this->timer, 95 | ); 96 | 97 | $subscriber->notify(event: new Finished( 98 | new Info( 99 | current: new Snapshot( 100 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 101 | memoryUsage: MemoryUsage::fromBytes(100), 102 | peakMemoryUsage: MemoryUsage::fromBytes(100), 103 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 104 | ), 105 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 106 | memorySinceStart: MemoryUsage::fromBytes(100), 107 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 108 | memorySincePrevious: MemoryUsage::fromBytes(100), 109 | ), 110 | 0 111 | )); 112 | 113 | $this->assertStringContainsString('There was at least one pattern that', $this->output); 114 | $this->assertStringContainsString('did not match any covered classes.', $this->output); 115 | $this->assertStringContainsString('Please consider removing them', $this->output); 116 | } 117 | 118 | public function testNotifyWhenCoverageIsOk(): void 119 | { 120 | $this->exitter 121 | ->expects($this->never()) 122 | ->method('exit'); 123 | 124 | $subscriber = new ApplicationFinishedSubscriber( 125 | pathToCloverXml: 'tests/clover.xml', 126 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-success.php'), 127 | cleanUpCloverXml: false, 128 | exitter: $this->exitter, 129 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 130 | timer: $this->timer, 131 | ); 132 | 133 | $subscriber->notify(event: new Finished( 134 | new Info( 135 | current: new Snapshot( 136 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 137 | memoryUsage: MemoryUsage::fromBytes(100), 138 | peakMemoryUsage: MemoryUsage::fromBytes(100), 139 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 140 | ), 141 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 142 | memorySinceStart: MemoryUsage::fromBytes(100), 143 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 144 | memorySincePrevious: MemoryUsage::fromBytes(100), 145 | ), 146 | 0 147 | )); 148 | 149 | $this->assertStringContainsString('All minimum code coverage rules', $this->output); 150 | $this->assertStringContainsString('passed, give yourself a pat on the', $this->output); 151 | } 152 | 153 | public function testNotifyWithOnlyTotal(): void 154 | { 155 | $this->exitter 156 | ->expects($this->never()) 157 | ->method('exit'); 158 | 159 | $subscriber = new ApplicationFinishedSubscriber( 160 | pathToCloverXml: 'tests/clover.xml', 161 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-total-only.php'), 162 | cleanUpCloverXml: false, 163 | exitter: $this->exitter, 164 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 165 | timer: $this->timer, 166 | ); 167 | 168 | $subscriber->notify(event: new Finished( 169 | new Info( 170 | current: new Snapshot( 171 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 172 | memoryUsage: MemoryUsage::fromBytes(100), 173 | peakMemoryUsage: MemoryUsage::fromBytes(100), 174 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 175 | ), 176 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 177 | memorySinceStart: MemoryUsage::fromBytes(100), 178 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 179 | memorySincePrevious: MemoryUsage::fromBytes(100), 180 | ), 181 | 0 182 | )); 183 | 184 | $this->assertStringContainsString('Total', $this->output); 185 | $this->assertStringContainsString('All minimum code coverage rules', $this->output); 186 | $this->assertStringContainsString('passed, give yourself a pat on the', $this->output); 187 | } 188 | 189 | public function testNotifyWithoutTotal(): void 190 | { 191 | $this->exitter 192 | ->expects($this->never()) 193 | ->method('exit'); 194 | 195 | $subscriber = new ApplicationFinishedSubscriber( 196 | pathToCloverXml: 'tests/clover.xml', 197 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-without-total.php'), 198 | cleanUpCloverXml: false, 199 | exitter: $this->exitter, 200 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 201 | timer: $this->timer, 202 | ); 203 | 204 | $subscriber->notify(event: new Finished( 205 | new Info( 206 | current: new Snapshot( 207 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 208 | memoryUsage: MemoryUsage::fromBytes(100), 209 | peakMemoryUsage: MemoryUsage::fromBytes(100), 210 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 211 | ), 212 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 213 | memorySinceStart: MemoryUsage::fromBytes(100), 214 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 215 | memorySincePrevious: MemoryUsage::fromBytes(100), 216 | ), 217 | 0 218 | )); 219 | 220 | $this->assertStringNotContainsString('Total', $this->output); 221 | $this->assertStringContainsString('All minimum code coverage rules', $this->output); 222 | $this->assertStringContainsString('passed, give yourself a pat on the', $this->output); 223 | } 224 | 225 | public function testNotifyWithRulesThatDoNotExit(): void 226 | { 227 | $this->exitter 228 | ->expects($this->never()) 229 | ->method('exit'); 230 | 231 | $subscriber = new ApplicationFinishedSubscriber( 232 | pathToCloverXml: 'tests/clover.xml', 233 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-no-exit.php'), 234 | cleanUpCloverXml: false, 235 | exitter: $this->exitter, 236 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 237 | timer: $this->timer, 238 | ); 239 | 240 | $subscriber->notify(event: new Finished( 241 | new Info( 242 | current: new Snapshot( 243 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 244 | memoryUsage: MemoryUsage::fromBytes(100), 245 | peakMemoryUsage: MemoryUsage::fromBytes(100), 246 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 247 | ), 248 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 249 | memorySinceStart: MemoryUsage::fromBytes(100), 250 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 251 | memorySincePrevious: MemoryUsage::fromBytes(100), 252 | ), 253 | 0 254 | )); 255 | 256 | $this->assertStringContainsString('No', $this->output); 257 | $this->assertStringContainsString('Not all minimum code coverage rules', $this->output); 258 | $this->assertStringContainsString('passed, please try again... :)', $this->output); 259 | } 260 | 261 | public function testDivideByZero(): void 262 | { 263 | $this->exitter 264 | ->expects($this->never()) 265 | ->method('exit'); 266 | 267 | $subscriber = new ApplicationFinishedSubscriber( 268 | pathToCloverXml: 'tests/clover-test-divide-by-zero.xml', 269 | minCoverageRules: MinCoverageRules::fromInt(100, true), 270 | cleanUpCloverXml: false, 271 | exitter: $this->exitter, 272 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 273 | timer: $this->timer, 274 | ); 275 | 276 | $subscriber->notify(event: new Finished( 277 | new Info( 278 | current: new Snapshot( 279 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 280 | memoryUsage: MemoryUsage::fromBytes(100), 281 | peakMemoryUsage: MemoryUsage::fromBytes(100), 282 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 283 | ), 284 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 285 | memorySinceStart: MemoryUsage::fromBytes(100), 286 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 287 | memorySincePrevious: MemoryUsage::fromBytes(100), 288 | ), 289 | 0 290 | )); 291 | 292 | $this->assertStringContainsString('There was at least one pattern that', $this->output); 293 | $this->assertStringContainsString('did not match any covered classes.', $this->output); 294 | $this->assertStringContainsString('Please consider removing them', $this->output); 295 | } 296 | 297 | public function testNotifyWhenNoTrackedLines(): void 298 | { 299 | $this->exitter 300 | ->expects($this->never()) 301 | ->method('exit'); 302 | 303 | $subscriber = new ApplicationFinishedSubscriber( 304 | pathToCloverXml: 'tests/clover-with-no-tracked-lines.xml', 305 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-no-tracked-lines.php'), 306 | cleanUpCloverXml: false, 307 | exitter: $this->exitter, 308 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 309 | timer: $this->timer, 310 | ); 311 | 312 | $subscriber->notify(event: new Finished( 313 | new Info( 314 | current: new Snapshot( 315 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 316 | memoryUsage: MemoryUsage::fromBytes(100), 317 | peakMemoryUsage: MemoryUsage::fromBytes(100), 318 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 319 | ), 320 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 321 | memorySinceStart: MemoryUsage::fromBytes(100), 322 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 323 | memorySincePrevious: MemoryUsage::fromBytes(100), 324 | ), 325 | 0 326 | )); 327 | 328 | $this->assertStringContainsString('There was at least one pattern that', $this->output); 329 | $this->assertStringContainsString('did not match any covered classes.', $this->output); 330 | $this->assertStringContainsString('Please consider removing them', $this->output); 331 | } 332 | 333 | public function testNotifyWithNonExistingCloverFile(): void 334 | { 335 | $this->exitter 336 | ->expects($this->never()) 337 | ->method('exit'); 338 | 339 | $subscriber = new ApplicationFinishedSubscriber( 340 | pathToCloverXml: 'tests/clover-wrong.xml', 341 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-success.php'), 342 | cleanUpCloverXml: false, 343 | exitter: $this->exitter, 344 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 345 | timer: $this->timer, 346 | ); 347 | 348 | $this->expectExceptionMessage('Clover XML file not found at:'); 349 | $subscriber->notify(event: new Finished( 350 | new Info( 351 | current: new Snapshot( 352 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 353 | memoryUsage: MemoryUsage::fromBytes(100), 354 | peakMemoryUsage: MemoryUsage::fromBytes(100), 355 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 356 | ), 357 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 358 | memorySinceStart: MemoryUsage::fromBytes(100), 359 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 360 | memorySincePrevious: MemoryUsage::fromBytes(100), 361 | ), 362 | 0 363 | )); 364 | } 365 | 366 | public function testNotifyWithInvalidCloverFile(): void 367 | { 368 | $this->exitter 369 | ->expects($this->never()) 370 | ->method('exit'); 371 | 372 | $subscriber = new ApplicationFinishedSubscriber( 373 | pathToCloverXml: 'tests/clover-invalid.xml', 374 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-success.php'), 375 | cleanUpCloverXml: false, 376 | exitter: $this->exitter, 377 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 378 | timer: $this->timer, 379 | ); 380 | 381 | $this->expectException(\RuntimeException::class); 382 | $this->expectExceptionMessage('Could not determine coverage metrics'); 383 | 384 | $subscriber->notify(event: new Finished( 385 | new Info( 386 | current: new Snapshot( 387 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 388 | memoryUsage: MemoryUsage::fromBytes(100), 389 | peakMemoryUsage: MemoryUsage::fromBytes(100), 390 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 391 | ), 392 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 393 | memorySinceStart: MemoryUsage::fromBytes(100), 394 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 395 | memorySincePrevious: MemoryUsage::fromBytes(100), 396 | ), 397 | 0 398 | )); 399 | } 400 | 401 | public function testNotifyWithCleanUpCloverFile(): void 402 | { 403 | copy(dirname(__DIR__, 2).'/clover.xml', dirname(__DIR__, 2).'/clover-to-delete.xml'); 404 | 405 | $this->exitter 406 | ->expects($this->once()) 407 | ->method('exit'); 408 | 409 | $subscriber = new ApplicationFinishedSubscriber( 410 | pathToCloverXml: 'tests/clover-to-delete.xml', 411 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-with-failed-rule.php'), 412 | cleanUpCloverXml: true, 413 | exitter: $this->exitter, 414 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 415 | timer: $this->timer, 416 | ); 417 | 418 | $subscriber->notify(event: new Finished( 419 | new Info( 420 | current: new Snapshot( 421 | time: HRTime::fromSecondsAndNanoseconds(1, 0), 422 | memoryUsage: MemoryUsage::fromBytes(100), 423 | peakMemoryUsage: MemoryUsage::fromBytes(100), 424 | garbageCollectorStatus: new GarbageCollectorStatus(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 425 | ), 426 | durationSinceStart: Duration::fromSecondsAndNanoseconds(1, 0), 427 | memorySinceStart: MemoryUsage::fromBytes(100), 428 | durationSincePrevious: Duration::fromSecondsAndNanoseconds(1, 0), 429 | memorySincePrevious: MemoryUsage::fromBytes(100), 430 | ), 431 | 0 432 | )); 433 | 434 | $this->assertFileDoesNotExist(dirname(__DIR__, 2).'/clover-to-delete.xml'); 435 | } 436 | 437 | public function testNotifyWithDuplicatePatterns(): void 438 | { 439 | $this->exitter 440 | ->expects($this->never()) 441 | ->method('exit'); 442 | 443 | $this->expectException(\RuntimeException::class); 444 | $this->expectExceptionMessage('Make sure all coverage rule patterns are unique'); 445 | 446 | new ApplicationFinishedSubscriber( 447 | pathToCloverXml: 'tests/clover.xml', 448 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-with-duplicates.php'), 449 | cleanUpCloverXml: false, 450 | exitter: $this->exitter, 451 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 452 | timer: $this->timer, 453 | ); 454 | } 455 | 456 | public function testNotifyWithInvalidRules(): void 457 | { 458 | $this->exitter 459 | ->expects($this->never()) 460 | ->method('exit'); 461 | 462 | $this->expectException(\RuntimeException::class); 463 | $this->expectExceptionMessage('MinCoverage has to be value between 0 and 100. 203 given'); 464 | 465 | new ApplicationFinishedSubscriber( 466 | pathToCloverXml: 'tests/clover.xml', 467 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-invalid.php'), 468 | cleanUpCloverXml: false, 469 | exitter: $this->exitter, 470 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 471 | timer: $this->timer, 472 | ); 473 | } 474 | 475 | public function testNotifyWithInvalidRuleInstances(): void 476 | { 477 | $this->exitter 478 | ->expects($this->never()) 479 | ->method('exit'); 480 | 481 | $this->expectException(\RuntimeException::class); 482 | $this->expectExceptionMessage('Make sure all coverage rules are of instance RobinIngelbrecht\PHPUnitCoverageTools\MinCoverage\MinCoverageRule'); 483 | 484 | new ApplicationFinishedSubscriber( 485 | pathToCloverXml: 'tests/clover.xml', 486 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-invalid-rule-instances.php'), 487 | cleanUpCloverXml: false, 488 | exitter: $this->exitter, 489 | consoleOutput: new ConsoleOutput($this->output, $this->resourceUsageFormatter), 490 | timer: $this->timer, 491 | ); 492 | } 493 | 494 | public function testFromConfigurationAndParameters(): void 495 | { 496 | $this->assertEquals( 497 | new ApplicationFinishedSubscriber( 498 | pathToCloverXml: 'tests/clover.xml', 499 | minCoverageRules: MinCoverageRules::fromInt(90, false), 500 | cleanUpCloverXml: true, 501 | exitter: new Exitter(), 502 | consoleOutput: ConsoleOutput::create(), 503 | timer: SystemTimer::create(), 504 | ), 505 | ApplicationFinishedSubscriber::fromConfigurationAndParameters( 506 | (new Builder())->build([ 507 | '--coverage-clover=tests/clover.xml', 508 | ]), 509 | ParameterCollection::fromArray([]), 510 | ['--min-coverage=90', '--clean-up-clover-xml'] 511 | ), 512 | ); 513 | } 514 | 515 | public function testFromConfigurationAndParameters2(): void 516 | { 517 | $this->assertEquals( 518 | new ApplicationFinishedSubscriber( 519 | pathToCloverXml: 'tests/clover.xml', 520 | minCoverageRules: MinCoverageRules::fromInt(90, true), 521 | cleanUpCloverXml: true, 522 | exitter: new Exitter(), 523 | consoleOutput: ConsoleOutput::create(), 524 | timer: SystemTimer::create(), 525 | ), 526 | ApplicationFinishedSubscriber::fromConfigurationAndParameters( 527 | (new Builder())->build([ 528 | '--coverage-clover=tests/clover.xml', 529 | ]), 530 | ParameterCollection::fromArray([ 531 | 'exitOnLowCoverage' => '1', 532 | ]), 533 | ['--min-coverage=90', '--clean-up-clover-xml'] 534 | ), 535 | ); 536 | } 537 | 538 | public function testFromConfigurationAndParametersFromFile(): void 539 | { 540 | $this->assertEquals( 541 | expected: new ApplicationFinishedSubscriber( 542 | pathToCloverXml: 'tests/clover.xml', 543 | minCoverageRules: MinCoverageRules::fromConfigFile('tests/Subscriber/Application/min-coverage-rules-success.php'), 544 | cleanUpCloverXml: false, 545 | exitter: new Exitter(), 546 | consoleOutput: ConsoleOutput::create(), 547 | timer: SystemTimer::create(), 548 | ), 549 | actual: ApplicationFinishedSubscriber::fromConfigurationAndParameters( 550 | configuration: (new Builder())->build([ 551 | '--coverage-clover=tests/clover.xml', 552 | ]), 553 | parameters: ParameterCollection::fromArray([]), 554 | args: ['--min-coverage="tests/Subscriber/Application/min-coverage-rules-success.php"'] 555 | ), 556 | ); 557 | } 558 | 559 | public function testFromConfigurationAndParametersWhenInvalidMinCoverage(): void 560 | { 561 | $this->assertNull( 562 | actual: ApplicationFinishedSubscriber::fromConfigurationAndParameters( 563 | configuration: (new Builder())->build([ 564 | '--coverage-clover=tests/clover.xml', 565 | ]), 566 | parameters: ParameterCollection::fromArray([]), 567 | args: ['--min-coverage=a-word'] 568 | ), 569 | ); 570 | } 571 | 572 | public function testFromConfigurationAndParametersWhenCoverageTooHigh(): void 573 | { 574 | $this->assertNull( 575 | actual: ApplicationFinishedSubscriber::fromConfigurationAndParameters( 576 | configuration: (new Builder())->build([ 577 | '--coverage-clover=tests/clover.xml', 578 | ]), 579 | parameters: ParameterCollection::fromArray([]), 580 | args: ['--min-coverage=101'] 581 | ), 582 | ); 583 | } 584 | 585 | public function testFromConfigurationAndParametersWhenRulesAreEmpty(): void 586 | { 587 | $this->assertNull( 588 | ApplicationFinishedSubscriber::fromConfigurationAndParameters( 589 | configuration: (new Builder())->build([ 590 | '--coverage-clover=tests/clover.xml', 591 | ]), 592 | parameters: ParameterCollection::fromArray([]), 593 | args: ['--min-coverage="tests/Subscriber/Application/min-coverage-rules-empty.php"'] 594 | ), 595 | ); 596 | } 597 | } 598 | --------------------------------------------------------------------------------