diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7ae08c60..40462f77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,3 +56,28 @@ jobs: report_type: test_results files: junit-unit.xml,junit-e2e.xml disable_search: true + + mutation: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + name: Mutation Testing + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: pcov + ini-values: zend.assertions=1 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Install fixture dependencies + run: composer install --prefer-dist --no-progress --working-dir=tests/Fixtures + + - name: Mutate changed lines + run: composer mutation diff --git a/.gitignore b/.gitignore index f4fc7879..b92deb61 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /composer.lock /.phpunit.cache/ /.deptrac.cache +/.infection/ /phpcs.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0cdf03fe..5a10738b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,22 @@ Each incomplete statement needs its own method; two in one method confuse parser `//hover:marker` puts the cursor on the last member access or function call on that line. Use it for complete code such as `$this->method()`. +### Mutation testing + +`composer mutation` runs [Infection](https://infection.github.io/) on the lines you changed in `src/`. +It fails if a mutant escapes, which means neither the unit tests nor PHPStan caught it. +CI runs the same check on pull requests. + +It compares your working tree against `origin/main`, so commit and fetch before running it. +If you work from a fork, `origin` needs to be this repository. + +When a mutant escapes, add a test that catches it. +If the mutation doesn't change behavior, so no test could catch it, rewrite the code so the mutation isn't possible. +If you can't, ask in the pull request about ignoring that `Class::method` in `infection.json5`. +Don't ignore by line number. + +Name tests after the behavior they check, not the mutant they catch. + ## Code Reviews Reviewing code should focus on things not covered by CI or automated tooling: diff --git a/composer.json b/composer.json index 6ec1a2de..dcc00583 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "brianium/paratest": "^7", "composer/composer": "^2.10", "deptrac/deptrac": "^4.7", + "infection/infection": "0.35.9", "phpstan/phpstan": "^2.3", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", @@ -50,6 +51,10 @@ "phpstan": "phpstan analyze", "deptrac": "deptrac analyse --no-progress", "layer-coverage": "deptrac debug:unassigned", + "mutation": [ + "Composer\\Config::disableProcessTimeout", + "infection --git-diff-lines --git-diff-base=origin/main" + ], "test": [ "@phpstan", "@deptrac", @@ -65,7 +70,8 @@ "config": { "sort-packages": true, "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": false + "dealerdirect/phpcodesniffer-composer-installer": false, + "infection/extension-installer": false } } } diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 00000000..1b54fec6 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,15 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": ["src"], + }, + "tmpDir": ".infection", + "logs": { + "text": ".infection/infection.log", + }, + "minMsi": 100, + "minCoveredMsi": 100, + "ignoreMsiWithNoMutations": true, + "staticAnalysisTool": "phpstan", + "testFrameworkExtraArgs": "--testsuite=unit", +}