Repository: bmitch/churn-php (php)

Issue:
Add mutation testing
The project has a very good code coverage but we don't have metrics about the quality of the tests.
A tool like [Infection](https://infection.github.io/) could help with that.


Discussion:

Patch:
diff --git a/.gitattributes b/.gitattributes
--- a/.gitattributes
+++ b/.gitattributes
@@ -10,6 +10,7 @@ CHANGELOG.md export-ignore
 churn.yml export-ignore
 CONTRIBUTING.md export-ignore
 img/ export-ignore
+infection.json export-ignore
 Makefile export-ignore
 manifest.xml export-ignore
 phpcs.xml export-ignore
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -128,8 +128,8 @@ jobs:
           name: coverage
           path: ./coverage-${{ matrix.vcs }}.xml
 
-  analysis:
-    name: "Static Analysis"
+  coverage:
+    name: "Code coverage"
     runs-on: ubuntu-latest
     # Run after "test-vcs" to gather all code coverage reports
     needs: [test-vcs]
@@ -144,6 +144,57 @@ jobs:
           coverage: "pcov"
           ini-values: "error_reporting=-1, display_errors=On, display_startup_errors=On, zend.assertions=1"
           php-version: "8.0"
+          tools: composer:v2
+
+      - name: Get composer cache directory
+        id: composercache
+        run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+      - name: Cache composer dependencies
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.composercache.outputs.dir }}
+          key: composer-${{ runner.os }}-8.0-${{ hashFiles('composer.*') }}
+          restore-keys: |
+            composer-${{ runner.os }}-8.0-
+            composer-${{ runner.os }}-
+            composer-
+
+      - name: "Install dependencies"
+        run: "composer update --no-interaction --no-scripts --no-progress --prefer-dist"
+
+      - name: "Calculate code coverage"
+        run: "vendor/bin/simple-phpunit --colors=always --testdox --testsuite churn-tests --coverage-clover=coverage.xml"
+
+      - name: "Download other code coverage reports"
+        uses: actions/download-artifact@v2
+        with:
+          name: coverage
+          path: ./coverage
+
+      - name: "List all coverage reports"
+        run: echo coverage_reports=./coverage/$(ls -m coverage/ | sed "s/, */,.\/coverage\//g") >> $GITHUB_ENV
+
+      - uses: codecov/codecov-action@v1
+        with:
+          files: ./coverage.xml,${{ env.coverage_reports }}
+          fail_ci_if_error: true
+          verbose: true
+
+  analysis:
+    name: "Static Analysis"
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: "Checkout"
+        uses: "actions/checkout@v2"
+
+      - name: "Install PHP"
+        uses: "shivammathur/setup-php@v2"
+        with:
+          coverage: "none"
+          ini-values: "error_reporting=-1, display_errors=On, display_startup_errors=On, zend.assertions=1"
+          php-version: "8.0"
           tools: composer:v2, composer-normalize
 
       - name: Get composer cache directory
@@ -187,29 +238,49 @@ jobs:
       - name: "Normalize composer.json"
         run: "composer-normalize --dry-run --no-check-lock --no-update-lock"
 
-      - name: "Calculate code coverage"
-        run: "vendor/bin/simple-phpunit --colors=always --testdox --testsuite churn-tests --coverage-clover=coverage.xml"
+  mutation:
+    name: "Mutation testing"
+    runs-on: ubuntu-latest
 
-      - name: "Download other code coverage reports"
-        uses: actions/download-artifact@v2
+    steps:
+      - name: "Checkout"
+        uses: "actions/checkout@v2"
+
+      - name: "Install PHP"
+        uses: "shivammathur/setup-php@v2"
         with:
-          name: coverage
-          path: ./coverage
+          coverage: "pcov"
+          ini-values: "error_reporting=-1, display_errors=On, display_startup_errors=On, zend.assertions=1"
+          php-version: "8.0"
+          tools: composer:v2, infection
 
-      - name: "List all coverage reports"
-        run: echo coverage_reports=./coverage/$(ls -m coverage/ | sed "s/, */,.\/coverage\//g") >> $GITHUB_ENV
+      - name: Get composer cache directory
+        id: composercache
+        run: echo "::set-output name=dir::$(composer config cache-files-dir)"
 
-      - uses: codecov/codecov-action@v1
+      - name: Cache composer dependencies
+        uses: actions/cache@v2
         with:
-          files: ./coverage.xml,${{ env.coverage_reports }}
-          fail_ci_if_error: true
-          verbose: true
+          path: ${{ steps.composercache.outputs.dir }}
+          key: composer-${{ runner.os }}-8.0-${{ hashFiles('composer.*') }}
+          restore-keys: |
+            composer-${{ runner.os }}-8.0-
+            composer-${{ runner.os }}-
+            composer-
+
+      - name: "Install dependencies"
+        run: "composer update --no-interaction --no-scripts --no-progress --prefer-dist"
+
+      - name: "Run Infection"
+        run: |
+          git fetch --depth=1 origin $GITHUB_BASE_REF
+          infection -j$(nproc) --logger-github --git-diff-filter=AM --git-diff-base=origin/$GITHUB_BASE_REF --ignore-msi-with-no-mutations --min-msi=85 --min-covered-msi=95
 
   build:
     name: "Build Phar"
     runs-on: ubuntu-latest
     # Build when everything else is ok
-    needs: [tests, analysis]
+    needs: [tests, analysis, coverage, mutation]
 
     steps:
       - name: "Checkout"
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
 /composer.lock
 /coverage.xml
 /docs/
+/infection.log
 /phpunit.xml
 /vendor/
 /vendor-bin/**/vendor
diff --git a/bin/CyclomaticComplexityAssessorRunner b/bin/CyclomaticComplexityAssessorRunner
--- a/bin/CyclomaticComplexityAssessorRunner
+++ b/bin/CyclomaticComplexityAssessorRunner
@@ -6,5 +6,11 @@ require_once __DIR__ . '/bootstrap.php';
 use Churn\Assessor\CyclomaticComplexityAssessor;
 
 $file = $argv[1];
+$contents = \file_get_contents($file);
+if ($contents === false) {
+    echo 0;
+    return;
+}
+
 $assessor = new CyclomaticComplexityAssessor();
-echo $assessor->assess($file);
+echo $assessor->assess($contents);
diff --git a/bin/app.php b/bin/app.php
--- a/bin/app.php
+++ b/bin/app.php
@@ -7,11 +7,11 @@
 use Composer\InstalledVersions;
 use Symfony\Component\Console\Application;
 
-$application = new Application('churn-php', (function (string $package): string {
+$application = new Application('churn-php', (static function (string $package): string {
     $version = InstalledVersions::getPrettyVersion($package);
     $ref = InstalledVersions::getReference($package);
     if ($ref) {
-        $version .= '@' . \substr($ref, 0, 7);
+        $version .= '@' . substr($ref, 0, 7);
     }
 
     return $version;
diff --git a/bin/bootstrap.php b/bin/bootstrap.php
--- a/bin/bootstrap.php
+++ b/bin/bootstrap.php
@@ -1,16 +1,24 @@
 <?php
 
+declare(strict_types=1);
+
 error_reporting(E_ALL);
 ini_set('display_errors', 'stderr');
 
-if (is_file($autoload = __DIR__ . '/../vendor/autoload.php')) {
-    require_once($autoload);
-} elseif (is_file($autoload = __DIR__ . '/../../../autoload.php')) {
-    require_once($autoload);
-} else {
-    fwrite(STDERR,
-        'You must set up the project dependencies first. Run the following command:'.PHP_EOL.
-        'composer install'.PHP_EOL
-    );
-    exit(1);
+foreach (
+    [
+    __DIR__ . '/../vendor/autoload.php',
+    __DIR__ . '/../../../autoload.php',
+    ] as $autoload
+) {
+    if (is_file($autoload)) {
+        return require_once $autoload;
+    }
 }
+
+fwrite(
+    STDERR,
+    'You must set up the project dependencies first. Run the following command:' . PHP_EOL .
+    'composer install' . PHP_EOL
+);
+exit(1);
diff --git a/infection.json b/infection.json
new file mode 100644
--- /dev/null
+++ b/infection.json
@@ -0,0 +1,15 @@
+{
+    "$schema": "https://raw.githubusercontent.com/infection/infection/0.26.0/resources/schema.json",
+    "source": {
+        "directories": [
+            "src"
+        ]
+    },
+    "logs": {
+        "text": "infection.log"
+    },
+    "mutators": {
+        "@default": true,
+	"@cast": false
+    }
+}
diff --git a/phpcs.xml b/phpcs.xml
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -7,6 +7,7 @@
   <config name="php_version" value="70103"/>
 
   <file>./src</file>
+  <file>./bin</file>
 
   <rule ref="PSR12"/>
 
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <phpunit
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
+    xsi:noNamespaceSchemaLocation="vendor/bin/.phpunit/phpunit.xsd"
     bootstrap="vendor/autoload.php"
     colors="true"
     convertDeprecationsToExceptions="true"
@@ -18,11 +18,11 @@
             <directory>tests/Unit</directory>
         </testsuite>
     </testsuites>
-    <filter>
-        <whitelist>
+    <coverage>
+        <include>
             <directory suffix=".php">src</directory>
-        </whitelist>
-    </filter>
+        </include>
+    </coverage>
     <php>
         <env name="SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT" value="1" />
     </php>
diff --git a/src/Assessor/CyclomaticComplexityAssessor.php b/src/Assessor/CyclomaticComplexityAssessor.php
--- a/src/Assessor/CyclomaticComplexityAssessor.php
+++ b/src/Assessor/CyclomaticComplexityAssessor.php
@@ -14,28 +14,25 @@ class CyclomaticComplexityAssessor
      *
      * @var integer
      */
-    protected $score = 0;
+    private $score = 0;
 
     /**
      * Asses the files cyclomatic complexity.
      *
-     * @param string $filePath Path and file name.
+     * @param string $contents The contents of a PHP file.
      */
-    public function assess(string $filePath): int
+    public function assess(string $contents): int
     {
         $this->score = 0;
-        $contents = $this->getFileContents($filePath);
-        if (false !== $contents) {
-            $this->hasAtLeastOneMethod($contents);
-            $this->countTheIfStatements($contents);
-            $this->countTheElseIfStatements($contents);
-            $this->countTheWhileLoops($contents);
-            $this->countTheForLoops($contents);
-            $this->countTheCaseStatements($contents);
-            $this->countTheTernaryOperators($contents);
-            $this->countTheLogicalAnds($contents);
-            $this->countTheLogicalOrs($contents);
-        }
+        $this->hasAtLeastOneMethod($contents);
+        $this->countTheIfStatements($contents);
+        $this->countTheElseIfStatements($contents);
+        $this->countTheWhileLoops($contents);
+        $this->countTheForLoops($contents);
+        $this->countTheCaseStatements($contents);
+        $this->countTheTernaryOperators($contents);
+        $this->countTheLogicalAnds($contents);
+        $this->countTheLogicalOrs($contents);
 
         if (0 === $this->score) {
             $this->score = 1;
@@ -49,7 +46,7 @@ public function assess(string $filePath): int
      *
      * @param string $contents File contents.
      */
-    protected function hasAtLeastOneMethod(string $contents): void
+    private function hasAtLeastOneMethod(string $contents): void
     {
         \preg_match("/[ ]function[ ]/", $contents, $matches);
 
@@ -65,7 +62,7 @@ protected function hasAtLeastOneMethod(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheIfStatements(string $contents): void
+    private function countTheIfStatements(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/[ ]if[ ]{0,}\(/", $contents);
     }
@@ -75,7 +72,7 @@ protected function countTheIfStatements(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheElseIfStatements(string $contents): void
+    private function countTheElseIfStatements(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/else[ ]{0,}if[ ]{0,}\(/", $contents);
     }
@@ -85,7 +82,7 @@ protected function countTheElseIfStatements(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheWhileLoops(string $contents): void
+    private function countTheWhileLoops(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/while[ ]{0,}\(/", $contents);
     }
@@ -95,7 +92,7 @@ protected function countTheWhileLoops(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheForLoops(string $contents): void
+    private function countTheForLoops(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/[ ]for(each){0,1}[ ]{0,}\(/", $contents);
     }
@@ -105,7 +102,7 @@ protected function countTheForLoops(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheCaseStatements(string $contents): void
+    private function countTheCaseStatements(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/[ ]case[ ]{1}(.*)\:/", $contents);
     }
@@ -115,7 +112,7 @@ protected function countTheCaseStatements(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheTernaryOperators(string $contents): void
+    private function countTheTernaryOperators(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/[ ]\?.*:.*;/", $contents);
     }
@@ -125,7 +122,7 @@ protected function countTheTernaryOperators(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheLogicalAnds(string $contents): void
+    private function countTheLogicalAnds(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/[ ]&&[ ]/", $contents);
     }
@@ -135,7 +132,7 @@ protected function countTheLogicalAnds(string $contents): void
      *
      * @param string $contents File contents.
      */
-    protected function countTheLogicalOrs(string $contents): void
+    private function countTheLogicalOrs(string $contents): void
     {
         $this->score += $this->howManyPatternMatches("/[ ]\|\|[ ]/", $contents);
     }
@@ -146,19 +143,8 @@ protected function countTheLogicalOrs(string $contents): void
      * @param string $pattern Regex pattern.
      * @param string $string Any string.
      */
-    protected function howManyPatternMatches(string $pattern, string $string): int
+    private function howManyPatternMatches(string $pattern, string $string): int
     {
         return (int) \preg_match_all($pattern, $string);
     }
-
-    /**
-     * Return the contents of the provided file at $filePath.
-     *
-     * @param string $filePath Path and filename.
-     * @return string|false The file content.
-     */
-    protected function getFileContents(string $filePath)
-    {
-        return \file_get_contents($filePath);
-    }
 }
diff --git a/src/Command/AssessComplexityCommand.php b/src/Command/AssessComplexityCommand.php
--- a/src/Command/AssessComplexityCommand.php
+++ b/src/Command/AssessComplexityCommand.php
@@ -34,8 +34,18 @@ protected function configure(): void
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
         $file = (string) $input->getArgument('file');
+        $contents = \is_file($file)
+            ? \file_get_contents($file)
+            : false;
+
+        if (false === $contents) {
+            $output->writeln('0');
+
+            return 0;
+        }
+
         $assessor = new CyclomaticComplexityAssessor();
-        $output->writeln((string) $assessor->assess($file));
+        $output->writeln((string) $assessor->assess($contents));
 
         return 0;
     }


Test patch:
diff --git a/tests/Integration/Command/AssessComplexityCommandTest.php b/tests/Integration/Command/AssessComplexityCommandTest.php
--- a/tests/Integration/Command/AssessComplexityCommandTest.php
+++ b/tests/Integration/Command/AssessComplexityCommandTest.php
@@ -28,7 +28,7 @@ protected function tearDown()
     }
 
     /** @test */
-    public function it_returns_the_cyclomatic_complexity()
+    public function it_returns_the_cyclomatic_complexity_greater_than_zero()
     {
         $exitCode = $this->commandTester->execute(['file' => __FILE__]);
         $result = \rtrim($this->commandTester->getDisplay());
@@ -37,4 +37,15 @@ public function it_returns_the_cyclomatic_complexity()
         $this->assertTrue(\ctype_digit($result), 'The result of the command must be an integer');
         $this->assertGreaterThan(0, (int) $result);
     }
+
+    /** @test */
+    public function it_returns_zero_for_non_existing_file()
+    {
+        $exitCode = $this->commandTester->execute(['file' => 'nonexisting-file.php']);
+        $result = \rtrim($this->commandTester->getDisplay());
+
+        $this->assertEquals(0, $exitCode);
+        $this->assertTrue(\ctype_digit($result), 'The result of the command must be an integer');
+        $this->assertEquals(0, (int) $result);
+    }
 }
diff --git a/tests/Unit/Assessor/CyclomaticComplexityAssessorTest.php b/tests/Unit/Assessor/CyclomaticComplexityAssessorTest.php
--- a/tests/Unit/Assessor/CyclomaticComplexityAssessorTest.php
+++ b/tests/Unit/Assessor/CyclomaticComplexityAssessorTest.php
@@ -9,12 +9,6 @@
 
 class CyclomaticComplexityAssessorTest extends BaseTestCase
 {
-    /** @test */
-    public function the_class_itself_has_a_complexity_of_four()
-    {
-        $this->assertEquals(4, $this->assess('src/Assessor/CyclomaticComplexityAssessor.php'));
-    }
-
     /** @test */
     public function an_empty_class_should_have_a_complexity_of_one()
     {
@@ -90,6 +84,9 @@ public function a_class_with_a_logical_or_should_return_two()
 
     protected function assess($filename)
     {
-        return (new CyclomaticComplexityAssessor())->assess($filename);
+        $contents = \file_get_contents($filename);
+        assert($contents !== false);
+
+        return (new CyclomaticComplexityAssessor())->assess($contents);
     }
 }