diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml index 033f923..dbdff68 100644 --- a/.github/workflows/php.yaml +++ b/.github/workflows/php.yaml @@ -85,3 +85,37 @@ jobs: # binary's GPG signature endpoint failing) must not fail the build. fail_ci_if_error: false flags: unittests + + mutation-tests: + name: Mutation tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + # The GITHUB_* variables are passed into the container so Infection's + # CI detection works: --logger-github emits inline PR annotations for + # escaped mutants, and the Stryker dashboard logger (configured in + # infection.json5) publishes the report on pushes to develop. The + # minimum mutation score is enforced via minCoveredMsi in + # infection.json5. + - name: Run Infection + env: + STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} + run: | + docker compose run --rm phpfpm composer install + docker compose run --rm \ + -e XDEBUG_MODE=coverage \ + -e GITHUB_ACTIONS \ + -e GITHUB_REF \ + -e GITHUB_HEAD_REF \ + -e GITHUB_SHA \ + -e GITHUB_REPOSITORY \ + -e GITHUB_EVENT_NAME \ + -e GITHUB_RUN_ID \ + -e GITHUB_SERVER_URL \ + -e STRYKER_DASHBOARD_API_KEY \ + phpfpm vendor/bin/infection --logger-github diff --git a/.gitignore b/.gitignore index 0d9eae0..4e5588e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ unit.xml # Local Claude Code instructions (not part of the published package) /CLAUDE.md + +###> infection/infection ### +/infection.log +/infection.html +###< infection/infection ### diff --git a/CHANGELOG.md b/CHANGELOG.md index e51abb7..7c13b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Dev: mutation testing with [Infection](https://infection.github.io/) + (`task test:mutation`). The minimum mutation score is configured in + `infection.json5` and enforced in CI; escaped mutants are annotated inline + on pull requests, and results for `develop` are published to the Stryker + dashboard (mutation score badge in the README). No effect on the published + package. + ### Changed - CI: bumped `codecov/codecov-action` from `v5` to `v7` (restores Codecov's diff --git a/README.md b/README.md index 529eae6..8911a56 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![PHP Version](https://img.shields.io/packagist/php-v/itk-dev/openid-connect-bundle.svg?style=flat-square&colorB=%238892BF)](https://www.php.net/downloads) [![Build Status](https://img.shields.io/github/actions/workflow/status/itk-dev/openid-connect-bundle/php.yaml?branch=develop&label=CI&logo=github&style=flat-square)](https://github.com/itk-dev/openid-connect-bundle/actions/workflows/php.yaml?query=branch%3Adevelop) [![Codecov Code Coverage](https://img.shields.io/codecov/c/gh/itk-dev/openid-connect-bundle?label=codecov&logo=codecov&style=flat-square)](https://codecov.io/gh/itk-dev/openid-connect-bundle) +[![Mutation Score](https://img.shields.io/endpoint?style=flat-square&label=mutation%20score&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fitk-dev%2Fopenid-connect-bundle%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/itk-dev/openid-connect-bundle/develop) [![Read License](https://img.shields.io/packagist/l/itk-dev/openid-connect-bundle.svg?style=flat-square&colorB=darkcyan)](https://github.com/itk-dev/openid-connect-bundle/blob/master/LICENSE.md) [![Package downloads on Packagist](https://img.shields.io/packagist/dt/itk-dev/openid-connect-bundle.svg?style=flat-square&colorB=darkmagenta)](https://packagist.org/packages/itk-dev/openid-connect-bundle/stats) @@ -472,6 +473,27 @@ task test:matrix This runs PHPUnit with coverage for each combination and prints a summary of pass/fail results. +### Mutation Testing + +Line coverage shows which code the tests *execute*; mutation testing shows +which code they actually *verify*. [Infection](https://infection.github.io/) +applies small changes (mutants) to the source code — flipping a comparison, +removing a method call — and runs the test suite against each one. If the +tests still pass, the mutant "escaped": a potential bug the tests would not +catch. + +```shell +task test:mutation +``` + +The minimum mutation score (`minCoveredMsi`) is defined in `infection.json5` +and enforced both locally and in CI — no command line flags needed. CI +annotates escaped mutants inline on pull requests, and results for `develop` +are published to the +[Stryker dashboard](https://dashboard.stryker-mutator.io/reports/github.com/itk-dev/openid-connect-bundle/develop), +which also feeds the mutation score badge above. Detailed reports are written +to `infection.log` and `infection.html` on each run. + ### PHPStan Static Analysis ```shell @@ -510,7 +532,8 @@ Run `task --list` to see all available tasks. ## CI -GitHub Actions are used to run the test suite and code style checks on all PRs. +GitHub Actions are used to run the test suite, mutation tests and code style +checks on all PRs. ## Versioning diff --git a/Taskfile.yml b/Taskfile.yml index 0eb711d..806f150 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -144,6 +144,11 @@ tasks: cmds: - "{{.DOCKER_COMPOSE}} exec -e XDEBUG_MODE=coverage phpfpm vendor/bin/phpunit --coverage-text --coverage-clover=coverage/unit.xml" + test:mutation: + desc: Run Infection mutation testing (threshold configured in infection.json5) + cmds: + - "{{.DOCKER_COMPOSE}} exec -e XDEBUG_MODE=coverage phpfpm vendor/bin/infection" + test:run: desc: "Run tests for a PHP version and dependency set (e.g. task test:run PHP=8.4 DEPS=lowest)" vars: @@ -250,3 +255,4 @@ tasks: - task: lint - task: analyze:php - task: test:matrix + - task: test:mutation diff --git a/composer.json b/composer.json index 45a9d7d..0c6f021 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "require-dev": { "ergebnis/composer-normalize": "^2.28", "friendsofphp/php-cs-fixer": "^3.11", + "infection/infection": "*", "phpstan/phpstan": "^2.1.41", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", @@ -51,6 +52,7 @@ "config": { "allow-plugins": { "ergebnis/composer-normalize": true, + "infection/extension-installer": true, "symfony/runtime": true }, "sort-packages": true diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..f672303 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,23 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": ["src"] + }, + "threads": "max", + // Minimum mutation score for covered code; enforced locally and in CI. + // Baseline measured at 80% — ratchet up as surviving mutants are killed. + "minCoveredMsi": 78, + "logs": { + "text": "infection.log", + "html": "infection.html", + // Publishes results for the develop branch to the Stryker dashboard + // (feeds the README badge). Requires STRYKER_DASHBOARD_API_KEY; only + // active on CI, skipped locally and on non-matching branches. + "stryker": { + "report": "develop" + } + }, + "mutators": { + "@default": true + } +}