diff --git a/README.md b/README.md index 5be961f69..00cfe8de6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ To make GrumPHP even more awesome, it will suggest installing some extra package - codegyre/robo : ~0.7 - doctrine/orm: ~2.5 - friendsofphp/php-cs-fixer : ~1|~2 +- infection/infection: ^0.5 - maglnet/composer-require-checker : ~0.1 - malukenho/kawaii-gherkin : ~0.1 - phing/phing : ~2.0 @@ -111,6 +112,7 @@ parameters: git_commit_message: ~ grunt: ~ gulp: ~ + infection: ~ jsonlint: ~ kahlan: ~ make: ~ diff --git a/composer.json b/composer.json index f3efe7918..2ea66e094 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "doctrine/orm": "Lets GrumPHP validate your Doctrine mapping files.", "etsy/phan": "Lets GrumPHP unleash a static analyzer on your code", "friendsofphp/php-cs-fixer": "Lets GrumPHP automatically fix your codestyle.", + "infection/infection": "Lets GrumPHP evaluate the quality your unit tests", "jakub-onderka/php-parallel-lint": "Lets GrumPHP quickly lint your entire code base.", "maglnet/composer-require-checker": "Lets GrumPHP analyze composer dependencies.", "malukenho/kawaii-gherkin": "Lets GrumPHP lint your Gherkin files.", diff --git a/doc/tasks.md b/doc/tasks.md index 13bcb769e..e7e8c5a60 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -25,6 +25,7 @@ parameters: git_commit_message: ~ grunt: ~ gulp: ~ + infection: ~ jsonlint: ~ kahlan: ~ make: ~ @@ -73,6 +74,7 @@ Every task has it's own default configuration. It is possible to overwrite the p - [Git commit message](tasks/git_commit_message.md) - [Grunt](tasks/grunt.md) - [Gulp](tasks/gulp.md) +- [Infection](tasks/infection.md) - [JsonLint](tasks/jsonlint.md) - [Kahlan](tasks/kahlan.md) - [Make](tasks/make.md) diff --git a/doc/tasks/infection.md b/doc/tasks/infection.md new file mode 100644 index 000000000..697322e1e --- /dev/null +++ b/doc/tasks/infection.md @@ -0,0 +1,96 @@ +# Infection + +Infection is a PHP mutation testing framework based on Abstract Syntax Tree. + +***Composer*** + +``` +composer require --dev infection/infection +``` + +***Config*** + +It lives under the `infection` namespace and has following configurable parameters: + +```yaml +# grumphp.yml +parameters: + tasks: + infection: + configuration: ~ + threads: ~ + test_framework: ~ + only_covered: false + configuration: ~ + min_msi: ~ + min_covered_msi: ~ + mutators: [] + triggered_by: [php] +``` + +**configuration** + +*Default: null* + +By defaut the `infection.json.dist` wil be used. +You can specify an alternate location for this file by changing this option. + + +**threads** + +*Default: null* + +If you want to run tests for mutated code in parallel, set this to something bigger than 1. +It will dramatically speed up the mutation process. +Please note that if your tests somehow depends on each other or use a database, this option can lead to failing tests which give many false-positives results. + + +**test_framework** + +*Default: null* + +This is the name of a test framework to use. Currently Infection supports `PhpUnit` and `PhpSpec`. + + +**only_covered** + +*Default: false* + +Run the mutation testing only for covered by tests files. + + +**configuration** + +*Default: null* + +The path or name to the infection configuration file. + + +**min_msi** + +*Default: null* + +This is a minimum threshold of Mutation Score Indicator (MSI) in percentage. + + +**min_covered_msi** + +*Default: null* + +This is a minimum threshold of Covered Code Mutation Score Indicator (MSI) in percentage. + + +**mutators** + +*Default: []* + +This is a list separated options to specify a particular set of mutators that needs to be executed. + + +**triggered_by** + +*Default: [php]* + +This option will specify which file extensions will trigger the infection task. +By default infection will be triggered by altering a php file. +You can overwrite this option to whatever file you want to use! diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml index 2de204d18..9d8d87d0c 100644 --- a/resources/config/tasks.yml +++ b/resources/config/tasks.yml @@ -156,6 +156,16 @@ services: - '@formatter.raw_process' tags: - {name: grumphp.task, config: gulp} + + task.infection: + class: GrumPHP\Task\Infection + arguments: + - '@config' + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, config: infection} + task.jsonlint: class: GrumPHP\Task\JsonLint arguments: diff --git a/spec/Collection/ProcessArgumentsCollectionSpec.php b/spec/Collection/ProcessArgumentsCollectionSpec.php index 708372f42..36033dea4 100644 --- a/spec/Collection/ProcessArgumentsCollectionSpec.php +++ b/spec/Collection/ProcessArgumentsCollectionSpec.php @@ -111,4 +111,15 @@ function it_should_be_able_to_add_comma_separated_files() $this->getValues()->shouldBe(['file1.txt,file2.txt']); } + + function it_should_be_able_to_add_an_argument_with_comma_separated_files() + { + $files = new FilesCollection([ + new SplFileInfo('file1.txt'), + new SplFileInfo('file2.txt') + ]); + $this->addArgumentWithCommaSeparatedFiles('--argument=%s', $files); + + $this->getValues()->shouldBe(['--argument=file1.txt,file2.txt']); + } } diff --git a/spec/Task/InfectionSpec.php b/spec/Task/InfectionSpec.php new file mode 100644 index 000000000..bd20e2844 --- /dev/null +++ b/spec/Task/InfectionSpec.php @@ -0,0 +1,111 @@ +getTaskConfiguration('infection')->willReturn([]); + $this->beConstructedWith($grumPHP, $processBuilder, $formatter); + } + + function it_is_initializable() + { + $this->shouldHaveType(Infection::class); + } + + function it_should_have_a_name() + { + $this->getName()->shouldBe('infection'); + } + + function it_should_have_configurable_options() + { + $options = $this->getConfigurableOptions(); + $options->shouldBeAnInstanceOf(OptionsResolver::class); + $options->getDefinedOptions()->shouldContain('threads'); + $options->getDefinedOptions()->shouldContain('test_framework'); + $options->getDefinedOptions()->shouldContain('only_covered'); + $options->getDefinedOptions()->shouldContain('configuration'); + $options->getDefinedOptions()->shouldContain('min_msi'); + $options->getDefinedOptions()->shouldContain('min_covered_msi'); + $options->getDefinedOptions()->shouldContain('mutators'); + $options->getDefinedOptions()->shouldContain('triggered_by'); + } + + function it_should_run_in_git_pre_commit_context(GitPreCommitContext $context) + { + $this->canRunInContext($context)->shouldReturn(true); + } + + function it_should_run_in_run_context(RunContext $context) + { + $this->canRunInContext($context)->shouldReturn(true); + } + + function it_does_not_do_anything_if_there_are_no_files(ProcessBuilder $processBuilder, ContextInterface $context) + { + $processBuilder->buildProcess('infection')->shouldNotBeCalled(); + $processBuilder->buildProcess()->shouldNotBeCalled(); + $context->getFiles()->willReturn(new FilesCollection()); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->getResultCode()->shouldBe(TaskResult::SKIPPED); + } + + function it_runs_the_suite(ProcessBuilder $processBuilder, Process $process, ContextInterface $context) + { + $arguments = new ProcessArgumentsCollection(); + $processBuilder->createArgumentsForCommand('infection')->willReturn($arguments); + $processBuilder->buildProcess($arguments)->willReturn($process); + + $process->run()->shouldBeCalled(); + $process->isSuccessful()->willReturn(true); + $process->getErrorOutput()->willReturn(''); + $process->getOutput()->willReturn(''); + + $context->getFiles()->willReturn(new FilesCollection([ + new SplFileInfo('test.php', '.', 'test.php') + ])); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(true); + } + + function it_throws_exception_if_the_process_fails(ProcessBuilder $processBuilder, Process $process, ContextInterface $context) + { + $arguments = new ProcessArgumentsCollection(); + $processBuilder->createArgumentsForCommand('infection')->willReturn($arguments); + $processBuilder->buildProcess($arguments)->willReturn($process); + + $process->run()->shouldBeCalled(); + $process->isSuccessful()->willReturn(false); + + $context->getFiles()->willReturn(new FilesCollection([ + new SplFileInfo('src/Collection/TaskResultCollection.php', 'src/Collection', 'TaskResultCollection.php'), + ])); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(false); + } +} diff --git a/src/Collection/ProcessArgumentsCollection.php b/src/Collection/ProcessArgumentsCollection.php index 6519866f0..ffc664cb3 100644 --- a/src/Collection/ProcessArgumentsCollection.php +++ b/src/Collection/ProcessArgumentsCollection.php @@ -135,4 +135,19 @@ public function addCommaSeparatedFiles(FilesCollection $files) $this->add(implode(',', $paths)); } + + /** + * @param string $argument + * @param FilesCollection|\SplFileInfo[] $files + */ + public function addArgumentWithCommaSeparatedFiles($argument, FilesCollection $files) + { + $paths = []; + + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $this->add(sprintf($argument, implode(',', $paths))); + } } diff --git a/src/Task/Infection.php b/src/Task/Infection.php new file mode 100644 index 000000000..9c45cc88d --- /dev/null +++ b/src/Task/Infection.php @@ -0,0 +1,97 @@ +setDefaults([ + 'threads' => null, + 'test_framework' => null, + 'only_covered' => false, + 'configuration' => null, + 'min_msi' => null, + 'min_covered_msi' => null, + 'mutators' => [], + 'triggered_by' => ['php'], + ]); + + $resolver->addAllowedTypes('threads', ['null', 'int']); + $resolver->addAllowedTypes('test_framework', ['null', 'string']); + $resolver->addAllowedTypes('only_covered', ['bool']); + $resolver->addAllowedTypes('configuration', ['null', 'string']); + $resolver->addAllowedTypes('min_msi', ['null', 'integer']); + $resolver->addAllowedTypes('min_covered_msi', ['null', 'integer']); + $resolver->addAllowedTypes('mutators', ['array']); + $resolver->addAllowedTypes('triggered_by', ['array']); + + return $resolver; + } + + /** + * {@inheritdoc} + */ + public function canRunInContext(ContextInterface $context) + { + return ($context instanceof GitPreCommitContext || $context instanceof RunContext); + } + + /** + * {@inheritdoc} + */ + public function run(ContextInterface $context) + { + $config = $this->getConfiguration(); + $files = $context->getFiles()->extensions($config['triggered_by']); + + if (0 === count($files)) { + return TaskResult::createSkipped($this, $context); + } + + $arguments = $this->processBuilder->createArgumentsForCommand('infection'); + $arguments->add('--no-interaction'); + $arguments->addOptionalArgument('--threads=%s', $config['threads']); + $arguments->addOptionalArgument('--test-framework=%s', $config['test_framework']); + $arguments->addOptionalArgument('--only-covered', $config['only_covered']); + $arguments->addOptionalArgument('--configuration=%s', $config['configuration']); + $arguments->addOptionalArgument('--min-msi=%s', $config['min_msi']); + $arguments->addOptionalArgument('--min-covered-msi=%s', $config['min_covered_msi']); + $arguments->addOptionalCommaSeparatedArgument('--mutators=%s', $config['mutators']); + + if ($context instanceof GitPreCommitContext) { + $arguments->addArgumentWithCommaSeparatedFiles('--filter=%s', $files); + } + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + return TaskResult::createFailed($this, $context, $this->formatter->format($process)); + } + + return TaskResult::createPassed($this, $context); + } +}