Mutation testing on pull requests

Updated . Numbers from the Reapr bench report. Read this guide as Markdown.

Code review asks one question about tests: do they protect the change? Coverage cannot answer it. A mutation run can, if it runs on the change and finishes while the pull request is open.

Why scope to the diff

A mutation engine changes your code on purpose, one small change at a time (a mutant), and runs your tests against each change. Run on a whole project, that is thousands of mutants. A signal that takes 40 to 90 minutes ends up in a nightly job, where nobody connects a surviving mutant to the change that caused it.

Scoped to the lines a branch changed, the run covers only the code under review, and its result (a score and a list of surviving mutants) belongs in the pull request, next to the code it is about.

Without Reapr: StrykerJS and git

StrykerJS accepts line ranges in mutate:

npx stryker run --mutate "src/app.ts:5-7,src/util.ts:12-30"

The ranges come from git. Compare against the merge base, with no context lines:

git diff --unified=0 origin/main...HEAD -- 'src/**/*.ts'

Each hunk header such as @@ -10,2 +12,4 @@ means lines 12 to 15 in the new file. Turn the headers into path:start-end ranges and pass them to --mutate. Three things to handle yourself:

With Reapr

Reapr does those steps and nothing else. It reads the diff from the merge base, keeps the ranges whose files your own mutate patterns select (matched the way Stryker matches them), runs Stryker with your config, and scores only the mutants inside the changed ranges:

rpr run --diff origin/main                 # human summary
rpr run --diff origin/main --min-score 80  # exit 1 below 80%
rpr run --diff origin/main --json          # one JSON document, for CI and agents

The wrapper config Reapr writes imports yours and overrides three keys: mutate, reporters (adds json) and jsonReporter.fileName. Your test runner, thresholds, timeouts, checkers and plugins stay as you wrote them.

On every pull request

The reapr-corp/run action installs rpr, runs it against the pull request’s base branch, and writes a job summary with the score, counts, survivors and warnings:

on: pull_request

jobs:
  rpr:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0 # full history, so origin/<base>...HEAD has a merge base
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - uses: reapr-corp/run@v1
        with:
          min-score: 80

In a monorepo with one Stryker config per package, list them in configs, one per line. Every config runs, and the summary combines them. A package the diff does not touch reports nothing to mutate.

What it costs

Scoping removes mutants, not startup. In Reapr’s bench report, a diff with 3 mutants still took a median of 54.0 s, and about 85% of that passed before the first test ran. How to speed up a slow Stryker run covers the settings that changed it.

Questions about this guide

How do I run Stryker only on the lines a pull request changed?
StrykerJS accepts line ranges in mutate, for example src/app.ts:5-7. Get the changed ranges from git diff --unified=0 against the merge base and pass them with --mutate. Reapr does this for you with rpr run --diff origin/main and keeps only the files your own mutate globs select.
Why not run mutation testing on the whole project in CI?
A full run on a real codebase can take 40 to 90 minutes, which ends up in a nightly job. A run scoped to the diff tests only the code under review, so its result belongs in the pull request.
Can I fail a pull request on a low mutation score?
Yes. rpr run --diff origin/main --min-score 80 exits with code 1 when the diff's score is below 80%, and the reapr-corp/run GitHub Action takes the same min-score input. Your config's own thresholds.break also works.