Mutation testing on pull requests
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:
- Your mutate globs. A list of ranges replaces your config’s
mutatepatterns, so test files and other excluded paths in the diff get mutated unless you filter them out first. - Deleted lines. A hunk that only deletes lines has no new lines to mutate. Skip it.
- Incremental mode. With
incremental: true, Stryker’s report keeps results from outside your ranges, so its score is no longer the score of the diff. See why Stryker’s incremental score is package-wide.
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.