# Mutation testing on pull requests

> Why mutation testing belongs on the pull request diff, and how to scope a StrykerJS run to it, with or without Reapr.

Source: https://reapr.dev/guides/mutation-testing-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`:

```sh
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:

```sh
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 `mutate` patterns, 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](/guides/stryker-incremental-score).

## 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:

```sh
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](https://github.com/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:

```yaml
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](https://github.com/reapr-corp/reapr/blob/main/bench/results/2026-11-02-turborepo-monorepo.md),
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](/guides/speed-up-stryker) covers the settings that changed it.

## Frequently asked questions

### 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.
