From 790c4885e5f7101ba313295a4e20a18a1e077077 Mon Sep 17 00:00:00 2001 From: Sandro Gehri Date: Mon, 20 Nov 2023 14:27:14 +0100 Subject: [PATCH] Add `min()` --- README.md | 6 ++--- docs/mutation-testing.md | 6 ++--- src/Contracts/Configuration.php | 2 +- src/Contracts/Printer.php | 2 ++ src/Decorators/TestCallDecorator.php | 4 ++-- src/MutationSuite.php | 20 ++++++++++++++++ .../{MinMsiOption.php => MinScoreOption.php} | 2 +- src/Repositories/ConfigurationRepository.php | 2 +- src/Repositories/MutationRepository.php | 5 ++++ .../Configuration/AbstractConfiguration.php | 10 ++++---- .../Configuration/CliConfiguration.php | 8 +++---- src/Support/Configuration/Configuration.php | 2 +- src/Support/Printers/DefaultPrinter.php | 16 +++++++++++-- src/Tester/MutationTestRunner.php | 23 +++++++++++++++++++ ...HandleTestCallProfileConfigurationTest.php | 4 ++-- .../HandlesCliProfileConfigurationTest.php | 8 +++---- .../HandlesGlobalProfileConfigurationTest.php | 6 ++--- 17 files changed, 92 insertions(+), 34 deletions(-) rename src/Options/{MinMsiOption.php => MinScoreOption.php} (96%) diff --git a/README.md b/README.md index 490712b..8dae6ce 100644 --- a/README.md +++ b/README.md @@ -368,11 +368,9 @@ Sometimes, you may want to ignore a specific mutation or line of code. To do so, } ``` -## Minimum Threshold Enforcement +## Minimum Score Threshold Enforcement -> WIP: The `min()` option is not implemented yet! - -Just like code coverage, mutation coverage can also be enforced. You can use the `--mutate` and `--min` options to define the minimum threshold values for the mutation score index. If the specified thresholds are not met, Pest will report a failure. +Just like code coverage, mutation coverage can also be enforced. You can use the `--mutate` and `--min` options to define the minimum threshold value for the mutation score. If the specified threshold is not met, Pest will report a failure. ```bash ./vendor/bin/pest --mutate --min=100 diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index 80f9d28..1cdbb60 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -360,11 +360,9 @@ Sometimes, you may want to ignore a specific mutation or line of code. To do so, } ``` -## Minimum Threshold Enforcement +## Minimum Score Threshold Enforcement -> WIP: The `min()` option is not implemented yet! - -Just like code coverage, mutation coverage can also be enforced. You can use the `--mutate` and `--min` options to define the minimum threshold values for the mutation score index. If the specified thresholds are not met, Pest will report a failure. +Just like code coverage, mutation coverage can also be enforced. You can use the `--mutate` and `--min` options to define the minimum threshold value for the mutation score. If the specified threshold is not met, Pest will report a failure. ```bash ./vendor/bin/pest --mutate --min=100 diff --git a/src/Contracts/Configuration.php b/src/Contracts/Configuration.php index 88ea10b..96aa317 100644 --- a/src/Contracts/Configuration.php +++ b/src/Contracts/Configuration.php @@ -21,7 +21,7 @@ public function mutator(array|string ...$mutators): self; */ public function except(array|string ...$mutators): self; - public function min(float $minMSI): self; + public function min(float $minScore): self; public function coveredOnly(bool $coveredOnly = true): self; diff --git a/src/Contracts/Printer.php b/src/Contracts/Printer.php index 6185807..366a477 100644 --- a/src/Contracts/Printer.php +++ b/src/Contracts/Printer.php @@ -20,6 +20,8 @@ public function reportTimedOutMutation(MutationTest $test): void; public function reportError(string $message): void; + public function reportScoreNotReached(float $scoreReached, float $scoreRequired): void; + public function printFilename(MutationTestCollection $testCollection): void; public function reportMutationGenerationStarted(MutationSuite $mutationSuite): void; diff --git a/src/Decorators/TestCallDecorator.php b/src/Decorators/TestCallDecorator.php index dec5bdb..a18fa5c 100644 --- a/src/Decorators/TestCallDecorator.php +++ b/src/Decorators/TestCallDecorator.php @@ -65,9 +65,9 @@ public function coveredOnly(bool $coveredOnly = true): self return $this; } - public function min(float $minMSI): self + public function min(float $minScore): self { - $this->configuration->min($minMSI); + $this->configuration->min($minScore); return $this; } diff --git a/src/MutationSuite.php b/src/MutationSuite.php index 984f550..523811a 100644 --- a/src/MutationSuite.php +++ b/src/MutationSuite.php @@ -4,7 +4,10 @@ namespace Pest\Mutate; +use Pest\Mutate\Repositories\ConfigurationRepository; use Pest\Mutate\Repositories\MutationRepository; +use Pest\Mutate\Support\Configuration\Configuration; +use Pest\Support\Container; class MutationSuite { @@ -44,4 +47,21 @@ public function trackFinish(): void { $this->finish = microtime(true); } + + public function score(): float + { + return $this->repository->score(); + } + + public function minScoreReached(): bool + { + /** @var Configuration $configuration */ + $configuration = Container::getInstance()->get(ConfigurationRepository::class)->mergedConfiguration(); // @phpstan-ignore-line + + if ($configuration->minScore === null) { + return true; + } + + return $configuration->minScore <= $this->score(); + } } diff --git a/src/Options/MinMsiOption.php b/src/Options/MinScoreOption.php similarity index 96% rename from src/Options/MinMsiOption.php rename to src/Options/MinScoreOption.php index 85a7e8b..62b1cc6 100644 --- a/src/Options/MinMsiOption.php +++ b/src/Options/MinScoreOption.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputOption; -class MinMsiOption +class MinScoreOption { final public const ARGUMENT = 'min'; diff --git a/src/Repositories/ConfigurationRepository.php b/src/Repositories/ConfigurationRepository.php index 6cdd7cf..4ad48a3 100644 --- a/src/Repositories/ConfigurationRepository.php +++ b/src/Repositories/ConfigurationRepository.php @@ -91,7 +91,7 @@ public function mergedConfiguration(): Configuration mutators: array_diff($config['mutators'] ?? DefaultSet::mutators(), $config['excluded_mutators'] ?? []), classes: $config['classes'] ?? [], parallel: $config['parallel'] ?? false, - minMSI: $config['min_msi'] ?? 0.0, + minScore: $config['min_score'] ?? null, stopOnSurvived: $config['stop_on_survived'] ?? false, stopOnNotCovered: $config['stop_on_not_covered'] ?? false, uncommittedOnly: $config['uncommitted_only'] ?? false, diff --git a/src/Repositories/MutationRepository.php b/src/Repositories/MutationRepository.php index cb496e6..8a55547 100644 --- a/src/Repositories/MutationRepository.php +++ b/src/Repositories/MutationRepository.php @@ -69,4 +69,9 @@ public function notRun(): int { return array_sum(array_map(fn (MutationTestCollection $testCollection): int => $testCollection->notRun(), $this->tests)); } + + public function score(): float + { + return ($this->killed() + $this->timedOut()) / $this->total() * 100; + } } diff --git a/src/Support/Configuration/AbstractConfiguration.php b/src/Support/Configuration/AbstractConfiguration.php index 47d6992..65b690c 100644 --- a/src/Support/Configuration/AbstractConfiguration.php +++ b/src/Support/Configuration/AbstractConfiguration.php @@ -31,7 +31,7 @@ abstract class AbstractConfiguration implements ConfigurationContract */ private ?array $classes = null; - private ?float $minMSI = null; + private ?float $minScore = null; private ?bool $coveredOnly = null; @@ -75,9 +75,9 @@ public function except(array|string ...$mutators): self return $this; } - public function min(float $minMSI): self + public function min(float $minScore): self { - $this->minMSI = $minMSI; + $this->minScore = $minScore; return $this; } @@ -143,7 +143,7 @@ public function changedOnly(?string $branch = 'main'): self } /** - * @return array{paths?: string[], mutators?: class-string[], excluded_mutators?: class-string[], classes?: string[], parallel?: bool, min_msi?: float, covered_only?: bool, stop_on_survived?: bool, stop_on_not_covered?: bool, uncommitted_only?: bool, changed_only?: string} + * @return array{paths?: string[], mutators?: class-string[], excluded_mutators?: class-string[], classes?: string[], parallel?: bool, min_score?: float, covered_only?: bool, stop_on_survived?: bool, stop_on_not_covered?: bool, uncommitted_only?: bool, changed_only?: string} */ public function toArray(): array { @@ -153,7 +153,7 @@ public function toArray(): array 'excluded_mutators' => $this->excludedMutators, 'classes' => $this->classes, 'parallel' => $this->parallel, - 'min_msi' => $this->minMSI, + 'min_score' => $this->minScore, 'covered_only' => $this->coveredOnly, 'stop_on_survived' => $this->stopOnSurvived, 'stop_on_not_covered' => $this->stopOnNotCovered, diff --git a/src/Support/Configuration/CliConfiguration.php b/src/Support/Configuration/CliConfiguration.php index 2320b5a..804d592 100644 --- a/src/Support/Configuration/CliConfiguration.php +++ b/src/Support/Configuration/CliConfiguration.php @@ -9,7 +9,7 @@ use Pest\Mutate\Options\ClassOption; use Pest\Mutate\Options\CoveredOnlyOption; use Pest\Mutate\Options\ExceptOption; -use Pest\Mutate\Options\MinMsiOption; +use Pest\Mutate\Options\MinScoreOption; use Pest\Mutate\Options\MutateOption; use Pest\Mutate\Options\MutatorsOption; use Pest\Mutate\Options\ParallelOption; @@ -26,7 +26,7 @@ class CliConfiguration extends AbstractConfiguration MutateOption::class, ClassOption::class, CoveredOnlyOption::class, - MinMsiOption::class, + MinScoreOption::class, MutatorsOption::class, ExceptOption::class, PathsOption::class, @@ -77,8 +77,8 @@ public function fromArguments(array $arguments): array $this->except(explode(',', (string) $input->getOption(ExceptOption::ARGUMENT))); // @phpstan-ignore-line } - if ($input->hasOption(MinMsiOption::ARGUMENT)) { - $this->min((float) $input->getOption(MinMsiOption::ARGUMENT)); // @phpstan-ignore-line + if ($input->hasOption(MinScoreOption::ARGUMENT)) { + $this->min((float) $input->getOption(MinScoreOption::ARGUMENT)); // @phpstan-ignore-line } if ($input->hasOption(CoveredOnlyOption::ARGUMENT)) { diff --git a/src/Support/Configuration/Configuration.php b/src/Support/Configuration/Configuration.php index 73a6c7d..15cfdfe 100644 --- a/src/Support/Configuration/Configuration.php +++ b/src/Support/Configuration/Configuration.php @@ -19,7 +19,7 @@ public function __construct( public readonly array $mutators, public readonly array $classes, public readonly bool $parallel, - public readonly float $minMSI, + public readonly ?float $minScore, public readonly bool $stopOnSurvived, public readonly bool $stopOnNotCovered, public readonly bool $uncommittedOnly, diff --git a/src/Support/Printers/DefaultPrinter.php b/src/Support/Printers/DefaultPrinter.php index 7d10cf4..29301bc 100644 --- a/src/Support/Printers/DefaultPrinter.php +++ b/src/Support/Printers/DefaultPrinter.php @@ -55,7 +55,16 @@ public function reportError(string $message): void { $this->output->writeln([ '', - ' ERROR '.$message.'', + ' ERROR '.$message.'', + '', + ]); + } + + public function reportScoreNotReached(float $scoreReached, float $scoreRequired): void + { + $this->output->writeln([ + '', + ' FAIL Code coverage below expected: '.number_format($scoreReached, 1).' %. Minimum: '.number_format($scoreRequired, 1).' %.', '', ]); } @@ -87,9 +96,12 @@ public function reportMutationSuiteFinished(MutationSuite $mutationSuite): void $this->output->writeln([ '', '', - ' Mutations: '.($mutationSuite->repository->survived() !== 0 ? $mutationSuite->repository->survived().' survived, ' : '').''.($mutationSuite->repository->notCovered() !== 0 ? $mutationSuite->repository->notCovered().' not covered, ' : '').''.($mutationSuite->repository->notRun() !== 0 ? $mutationSuite->repository->notRun().' pending, ' : '').''.($mutationSuite->repository->timedOut() !== 0 ? $mutationSuite->repository->timedOut().' timeout, ' : '').''.$mutationSuite->repository->killed().' killed', + ' Mutations: '.($mutationSuite->repository->survived() !== 0 ? ''.$mutationSuite->repository->survived().' survived, ' : '').($mutationSuite->repository->notCovered() !== 0 ? ''.$mutationSuite->repository->notCovered().' not covered, ' : '').($mutationSuite->repository->notRun() !== 0 ? ''.$mutationSuite->repository->notRun().' pending, ' : '').($mutationSuite->repository->timedOut() !== 0 ? ''.$mutationSuite->repository->timedOut().' timeout, ' : '').''.$mutationSuite->repository->killed().' killed', ]); + $score = number_format($mutationSuite->score(), 2); + $this->output->writeln(' Score: minScoreReached() ? 'default' : 'red').'>'.$score.'%'); + $duration = number_format($mutationSuite->duration(), 2); $this->output->writeln(' Duration: '.$duration.'s'); diff --git a/src/Tester/MutationTestRunner.php b/src/Tester/MutationTestRunner.php index 93e61d9..3b62bec 100644 --- a/src/Tester/MutationTestRunner.php +++ b/src/Tester/MutationTestRunner.php @@ -170,6 +170,8 @@ classesToMutate: $this->getConfiguration()->classes, Facade::instance()->emitter()->finishMutationSuite($mutationSuite); + $this->ensureMinScoreIsReached($mutationSuite); + exit(0); // TODO: exit with error on failure } @@ -204,4 +206,25 @@ private function getConfiguration(): Configuration { return Container::getInstance()->get(ConfigurationRepository::class)->mergedConfiguration(); // @phpstan-ignore-line } + + private function ensureMinScoreIsReached(MutationSuite $mutationSuite): void + { + $minScore = Container::getInstance()->get(ConfigurationRepository::class) // @phpstan-ignore-line + ->mergedConfiguration() + ->minScore; + + if ($minScore === null) { + return; + } + + $score = $mutationSuite->score(); + if ($score >= $minScore) { + return; + } + + Container::getInstance()->get(Printer::class) // @phpstan-ignore-line + ->reportScoreNotReached($score, $minScore); + + exit(1); + } } diff --git a/tests/Features/Profiles/HandleTestCallProfileConfigurationTest.php b/tests/Features/Profiles/HandleTestCallProfileConfigurationTest.php index 8a3da7d..4773eca 100644 --- a/tests/Features/Profiles/HandleTestCallProfileConfigurationTest.php +++ b/tests/Features/Profiles/HandleTestCallProfileConfigurationTest.php @@ -19,10 +19,10 @@ })->mutate(ConfigurationRepository::FAKE) ->throws('test exception'); -it('sets the min MSI from test', function (): void { +it('sets the min score threshold from test', function (): void { $configuration = $this->repository->fakeTestConfiguration(ConfigurationRepository::FAKE.'_1'); - expect($configuration->toArray()['min_msi']) + expect($configuration->toArray()['min_score']) ->toEqual(2.0); })->mutate(ConfigurationRepository::FAKE.'_1') ->min(2); diff --git a/tests/Features/Profiles/HandlesCliProfileConfigurationTest.php b/tests/Features/Profiles/HandlesCliProfileConfigurationTest.php index 69d562a..973d777 100644 --- a/tests/Features/Profiles/HandlesCliProfileConfigurationTest.php +++ b/tests/Features/Profiles/HandlesCliProfileConfigurationTest.php @@ -48,18 +48,18 @@ ->mutators->toHaveCount(count(ArithmeticSet::mutators()) - 2); }); -it('sets MSI threshold if --min argument is passed', function (): void { +it('sets min score threshold if --min argument is passed', function (): void { $this->configuration->fromArguments(['--mutate='.ConfigurationRepository::FAKE]); expect($this->configuration->toArray()) - ->min_msi->toEqual(0.0); + ->min_score->toEqual(0.0); $this->configuration->fromArguments(['--min=2']); expect($this->configuration->toArray()) - ->min_msi->toEqual(2.0); + ->min_score->toEqual(2.0); $this->configuration->fromArguments(['--min=2.4']); expect($this->configuration->toArray()) - ->min_msi->toEqual(2.4); + ->min_score->toEqual(2.4); }); it('enables covered only option if --covered-only argument is passed', function (): void { diff --git a/tests/Features/Profiles/HandlesGlobalProfileConfigurationTest.php b/tests/Features/Profiles/HandlesGlobalProfileConfigurationTest.php index ada93f8..56e6a3b 100644 --- a/tests/Features/Profiles/HandlesGlobalProfileConfigurationTest.php +++ b/tests/Features/Profiles/HandlesGlobalProfileConfigurationTest.php @@ -18,7 +18,7 @@ mutate(ConfigurationRepository::FAKE) ->min(20.0); - expect($this->configuration->toArray()['min_msi']) + expect($this->configuration->toArray()['min_score']) ->toEqual(20.0); }); @@ -78,11 +78,11 @@ ->toHaveCount(count(ArithmeticSet::mutators()) - 2); }); -test('globally configure min MSI threshold', function (): void { +test('globally configure min score threshold', function (): void { mutate(ConfigurationRepository::FAKE) ->min(10.0); - expect($this->configuration->toArray()['min_msi']) + expect($this->configuration->toArray()['min_score']) ->toEqual(10.0); });