CoverageChecker   A
last analyzed

Complexity

Total Complexity 22

Size/Duplication

Total Lines 229
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 103
c 2
b 0
f 0
dl 0
loc 229
ccs 100
cts 100
cp 1
rs 10
wmc 22

5 Methods

Rating   Name   Duplication   Size   Complexity  
A configure() 0 35 1
B validate() 0 41 8
A message() 0 37 5
A execute() 0 43 6
A coverageFromXml() 0 15 2
1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of phpunit-coverage-check.
4
 *
5
 * (c) Thor Juhasz <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace PHPUnitCoverageCheck;
12
13
use Exception;
14
use InvalidArgumentException;
15
use OutOfRangeException;
16
use SimpleXMLElement;
17
use Symfony\Component\Console\Command\Command;
18
use Symfony\Component\Console\Input\InputArgument;
19
use Symfony\Component\Console\Input\InputInterface;
20
use Symfony\Component\Console\Input\InputOption;
21
use Symfony\Component\Console\Output\OutputInterface;
22
use function bccomp;
23
use function bcdiv;
24
use function bcmul;
25
use function file_exists;
26
use function file_get_contents;
27
use function gettype;
28
use function in_array;
29
use function is_numeric;
30
use function is_string;
31
use function join;
32
use function sprintf;
33
34
class CoverageChecker extends Command
35
{
36
    private const METRIC_ELEMENTS = 'elements';
37
38
    private const METRIC_STATEMENTS = 'statements';
39
40
    private const METRIC_METHODS = 'methods';
41
42
    public static array $allowedMetrics = [
43
        self::METRIC_ELEMENTS,
44
        self::METRIC_STATEMENTS,
45
        self::METRIC_METHODS,
46
    ];
47
48
    /**
49
     * {@inheritDoc}
50
     */
51 1008
    protected function configure(): void
52
    {
53 1008
        $this->setName('phpunit-coverage-check')
54 1008
            ->setDescription(
55
                <<<EOT
56 1008
                A PHPUnit test coverage checker.
57
58
                  This command will parse a clover.xml report file (generated by PHPUnit),
59
                  to check that the test coverage meets a certain threshold (80% by default).
60
                EOT
61
            )
62 1008
            ->addArgument(
63 1008
                'file',
64 1008
                InputArgument::REQUIRED,
65 1008
                'Clover XML report file.'
66
            )
67 1008
            ->addOption(
68 1008
                'threshold',
69 1008
                't',
70 1008
                InputOption::VALUE_REQUIRED,
71 1008
                'Coverage threshold. Must be a number ranging from 0 to 100.',
72 1008
                '80'
73
            )
74 1008
            ->addOption(
75 1008
                'metric',
76 1008
                'm',
77 1008
                InputOption::VALUE_REQUIRED,
78 1008
                'The metric to measure coverage from. Possible values are <comment>elements, statements & methods</comment>.',
79 1008
                'elements'
80
            )
81 1008
            ->addOption(
82 1008
                'suppress-errors',
83 1008
                's',
84 1008
                InputOption::VALUE_NONE,
85 1008
                'Set to true so command will not give a non-zero exit code when the test coverage is under the threshold.'
86
            );
87
88 1008
    }
89
90
    /**
91
     * {@inheritDoc}
92
     *
93
     * @throws Exception
94
     */
95 1008
    protected function execute(InputInterface $input, OutputInterface $output): int
96
    {
97
        /** @var string|bool|int|float|null $file */
98 1008
        $file = $input->getArgument('file');
99 1008
        if ($file !== null) {
100 840
            $file = (string) $file;
101
        }
102
103
        /** @var string|bool|int|float|null $threshold */
104 1008
        $threshold = $input->getOption('threshold');
105 1008
        if ($threshold !== null) {
106 1008
            $threshold = (string) $threshold;
107
        }
108
109
        /** @var string|bool|int|float|null $metric */
110 1008
        $metric         = $input->getOption('metric');
111 1008
        if ($metric !== null) {
112 1008
            $metric = (string) $metric;
113
        }
114
115 1008
        $suppressErrors = (bool) $input->getOption('suppress-errors');
116
117
        // Validate
118 1008
        $this->validate($file, $threshold, $metric);
119
120
        /**
121
         * @var string                 $file
122
         * @var string                 $threshold
123
         * @var string                 $metric
124
         * @var string                 $suppressErrors
125
         *
126
         * @psalm-var static::METRIC_* $metric
127
         * @psalm-var numeric-string   $threshold
128
         */
129
130 192
        $xml = new SimpleXMLElement(file_get_contents($file));
131
132 192
        $coverage     = $this->coverageFromXml($xml, $metric);
133 192
        $isAcceptable = bccomp($coverage, $threshold, 2) !== -1;
134
135 192
        $this->message($coverage, $threshold, $isAcceptable, $output);
136
137 192
        return $isAcceptable || $suppressErrors ? 0 : 1;
138
    }
139
140
    /**
141
     * @param mixed $file
142
     * @param string|null $threshold
143
     * @param string|int|null $metric
144
     *
145
     * @throws InvalidArgumentException
146
     * @throws OutOfRangeException
147
     */
148 1008
    private function validate($file, ?string $threshold, $metric): void
149
    {
150 1008
        if (is_string($file) === false) {
151 168
            throw new InvalidArgumentException(
152 168
                'You must specify the name of the clover XML report file as an argument.'
153
            );
154
        }
155
156 840
        if (file_exists($file) === false) {
157 336
            $message = sprintf(
158 336
                'The clover XML report file could not be found (%s).',
159 336
                $file
160
            );
161 336
            throw new InvalidArgumentException($message);
162
        }
163
164 504
        $threshold = (string) $threshold;
165
166 504
        if (is_numeric($threshold) === false) {
167 72
            throw new InvalidArgumentException(
168 72
                'The test coverage threshold must be a number between 0 and 100 (inclusive).'
169
            );
170
        }
171
172
        if (
173 432
            bccomp($threshold, "0", 2) === -1 || bccomp($threshold, "100", 2) === 1
174
        ) {
175 144
            $message = sprintf(
176 144
                'The test coverage threshold should be a number between 0 and 100 (inclusive), %d given.',
177 144
                $threshold
178
            );
179 144
            throw new OutOfRangeException($message);
180
        }
181
182 288
        if (in_array($metric, static::$allowedMetrics) === false) {
183 96
            $message = sprintf(
184 96
                'The metric to be measured must be one of "%s". %s given.',
185 96
                join(", ", static::$allowedMetrics),
186 96
                $metric ?: 'null'
187
            );
188 96
            throw new InvalidARgumentException($message);
189
        }
190 192
    }
191
192
    /**
193
     * @param SimpleXMLElement $xml
194
     * @param string           $metric
195
     *
196
     * @return string
197
     *
198
     * @psalm-return numeric-string
199
     */
200 192
    private function coverageFromXml(SimpleXMLElement $xml, string $metric): string
201
    {
202 192
        $foundMetrics   = $xml->xpath('//metrics');
203 192
        $totalMetrics   = 0;
204 192
        $coveredMetrics = 0;
205
206 192
        foreach ($foundMetrics as $currentMetric) {
207 192
            $totalMetrics   += (int) $currentMetric[$metric];
208 192
            $coveredMetrics += (int) $currentMetric['covered' . $metric];
209
        }
210
211
        /** @psalm-var numeric-string $div */
212 192
        $div = bcdiv((string) $coveredMetrics, (string) $totalMetrics, 2);
213
214 192
        return bcmul($div, "100", 2);
215
    }
216
217
    /**
218
     * @param string               $coverage
219
     * @param string               $threshold
220
     * @param bool                 $isAcceptable
221
     * @param OutputInterface      $output
222
     *
223
     * @psalm-param numeric-string $coverage
224
     * @psalm-param numeric-string $threshold
225
     */
226 192
    private function message(string $coverage, string $threshold, bool $isAcceptable, OutputInterface $output): void
227
    {
228 192
        $coverageDisplay = sprintf("%.2f%%", $coverage);
229 192
        if ((float) $coverage === (float) sprintf("%d", $coverage)) {
230 192
            $coverageDisplay = sprintf("%d%%", $coverage);
231
        }
232
233 192
        $thresholdDisplay = sprintf("%.2f%%", $threshold);
234 192
        if ((float) $threshold === (float) sprintf("%d", $threshold)) {
235 192
            $thresholdDisplay = sprintf("%d%%", $threshold);
236
        }
237
238 192
        $fullCoverage = $thresholdDisplay === "100%";
239 192
        $suffix       = sprintf(
240 192
            '(requires %s)',
241 192
            $fullCoverage ? 'full coverage' : sprintf('>= %s coverage', $thresholdDisplay)
242
        );
243
244 192
        if ($isAcceptable === false) {
245 80
            $message = sprintf(
246 80
                '<error>[ERROR] Code coverage is %s, which is not acceptable %s</error>',
247 80
                $coverageDisplay,
248 80
                $suffix
249
            );
250
251 80
            $output->writeln($message);
252
253 80
            return;
254
        }
255
256 112
        $message = sprintf(
257 112
            '<info>[OK] Code coverage is %s, which is acceptable %s</info>',
258 112
            $coverageDisplay,
259 112
            $suffix
260
        );
261
262 112
        $output->writeln($message);
263 112
    }
264
}
265