Mutation Testing skill for Claude Code
Find out if your tests would notice a real bug. Claude runs mutation testing where it pays off, on critical modules or changed files, and triages every surviving mutant: missing boundary case, weak assertion, untested error path, equivalent mutant, or dead code. Survivors turn into small behavior-named tests, ignores carry a written reason, and CI gates only the modules that matter.
- name
mutation-testing - size167 lines · ≈1,700 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Measures how strong a test suite really is by running mutation testing with Stryker (JavaScript/TypeScript, .NET), mutmut (Python), PIT (Java), or cargo-mutants (Rust), then triages surviving mutants into missing assertions, untested branches, equivalent mutants, and dead code. Covers scoping runs to changed files, incremental mode, thresholds, CI gating, and turning survivors into targeted tests. Use when coverage is high but bugs still ship, when asked how good the tests are, when reviewing test quality for critical modules, or when setting up mutation testing in CI. Try asking
- We have 90% coverage but bugs keep slipping through. How good are our tests really?
- Set up Stryker for the billing module and fix the surviving mutants
- Run mutmut on src/pricing and explain which survivors matter
Use it when
- Coverage is high but defects still escape
- Hardening tests on critical domain logic
- Reviewing whether a test suite actually asserts behavior
- Adding a test-strength gate to CI
Skip it when
- The suite is flaky or slow: fix that first with flaky-test-triage
- You need to write the first tests for new code: use unit-testing
How to check the result
Re-running the tool on the scoped files shows the targeted mutants killed, the score above the agreed threshold, and every ignored mutant carrying a reason.
The common mistake it prevents
Running mutation testing on the whole repository at once, producing hours of runtime and thousands of survivors that nobody triages.
Coverage says code ran; mutation score says tests would notice if it were wrong. Done well: runs are scoped to code that matters, every surviving mutant is triaged with a decision, and survivors become precise new assertions, not more coverage padding.
Ground rules
- Start scoped: one critical module or the files changed in a PR. Whole-repo runs on a large codebase take hours and produce noise nobody reads.
- The test suite must be green and deterministic first. Flaky tests produce random “killed” results.
- A surviving mutant is a question (“would a test notice this?”), not automatically a missing test. Equivalent mutants exist; mark them and move on.
- Do not chase 100%. Typical useful targets: 80%+ for core domain logic, 60%+ elsewhere.
Workflow
- [ ] 1. Confirm the suite is green, fast, and deterministic
- [ ] 2. Install and scope the tool to one module or changed files
- [ ] 3. Run with per-test coverage analysis; read the report
- [ ] 4. Triage every survivor and NoCoverage mutant
- [ ] 5. Add targeted assertions; re-run to confirm kills
- [ ] 6. Set thresholds and wire incremental runs into CI
2. Tools and setup
| Stack | Tool | Start command |
|---|---|---|
| JS/TS (Jest, Vitest, Mocha, Karma) | StrykerJS | npm init stryker@latest then npx stryker run |
| C# / .NET | Stryker.NET | dotnet tool install -g dotnet-stryker then dotnet stryker |
| Python (pytest) | mutmut | pip install mutmut then mutmut run |
| Java / Kotlin (JVM) | PIT (pitest) | Maven: mvn org.pitest:pitest-maven:mutationCoverage (Gradle plugin available) |
| Rust | cargo-mutants | cargo install cargo-mutants then cargo mutants |
StrykerJS config (Vitest example):
// stryker.config.mjs
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
testRunner: 'vitest', // needs @stryker-mutator/vitest-runner
mutate: ['src/billing/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts'],
coverageAnalysis: 'perTest',
reporters: ['clear-text', 'progress', 'html'],
thresholds: { high: 85, low: 70, break: 60 },
incremental: true, // reuses results from reports/stryker-incremental.json
concurrency: 4,
};
Scope a run to specific files from the CLI: npx stryker run --mutate "src/billing/invoice.ts".
mutmut (3.x) config:
# pyproject.toml
[tool.mutmut]
paths_to_mutate = ["src/billing/"]
tests_dir = ["tests/billing/"]
mutmut run # run mutants
mutmut results # list survivors
mutmut show <mutant> # diff of one mutant
mutmut browse # interactive TUI for triage
Config keys differ between mutmut 2.x and 3.x; check mutmut --version and the project’s
docs before copying settings.
3. Read the report
| Status | Meaning | Action |
|---|---|---|
| Killed | A test failed with the mutant | Good |
| Survived | Tests ran the code and still passed | Triage (below) |
| No coverage | No test executes this code | Untested code: test it or delete it |
| Timeout | Mutant caused an infinite loop/hang | Usually counts as killed; check the timeout is sane |
| Compile/runtime error | Mutant invalid (type error) | Ignored in score |
| Ignored | Excluded by config or comment | Review exclusions periodically |
Mutation score = killed / (killed + survived + no coverage), with timeouts usually counted as detected.
4. Triage survivors
For each surviving mutant, read the diff and classify:
| Class | Example | Fix |
|---|---|---|
| Missing assertion | total > 100 → total >= 100 survives; no test at exactly 100 | Add the boundary case |
| Weak assertion | Return value changed, test only checks “no throw” or toBeDefined() | Assert the actual value |
| Untested branch | Error path if (!user) throw mutated to if (false) survives | Add a test for the error path |
| Side effect unchecked | Call to audit.log() removed, nothing fails | Assert the outgoing side effect if it is a requirement |
| Equivalent mutant | i < len → i != len in a loop with no skipping; behavior identical | Mark ignored with a reason |
| Dead or redundant code | Mutated defensive check is unreachable | Delete the code |
| Logging/telemetry | String literal in a log message mutated | Exclude log calls from mutation via config |
Ignore with an explanation so the next reader trusts the exclusion:
// Stryker disable next-line EqualityOperator: loop bound is equivalent for contiguous arrays
for (let i = 0; i < items.length; i++) { ... }
retry_delay = 0.5 # pragma: no mutate (tuning constant, not behavior)
5. Write kill-tests
Write the smallest test that distinguishes the mutant from the original, named for the behavior, not the mutant:
it('applies free shipping at exactly the 100.00 threshold', () => {
expect(shippingFor(cartTotal(10_000))).toBe(0);
});
Re-run only the affected file and confirm the mutant is now killed. If several survivors cluster in one function, the function probably lacks a table of boundary cases; add it once (see unit-testing).
6. CI
- On PRs: incremental or changed-files runs only (
--mutatewith the PR’s changed source files, or Strykerincrementalwith the baseline file cached between runs). - Nightly: full run on critical modules; publish the HTML report as an artifact.
- Gate with
thresholds.break(Stryker exits non-zero below it) on critical modules only. Ratchet the threshold up as the score improves.
Runtime control: per-test coverage analysis (coverageAnalysis: 'perTest') runs only
tests that cover each mutant and is the biggest speedup. Exclude generated code,
migrations, and config files from mutate.
Gotchas
- Tests with real time, network, or randomness make mutants randomly survive or die.
- Very slow suites multiply: 2,000 mutants x 30 s = hours. Fix test speed or scope harder.
- Snapshot tests kill many mutants without proving behavior; a high score from snapshots is weaker than it looks.
- Static mutants (module-level constants evaluated once) need a full test run per mutant in Stryker; they are slow, consider excluding them.
- Score drops after deleting dead code are impossible; score drops after adding untested code are the signal you want.
Output
**Scope:** src/billing (412 mutants) — StrykerJS 9, Vitest runner, perTest
**Score:** 71.4% → 88.9% (killed 366, survived 31, no coverage 15 → 0)
**Fixed:** 12 boundary assertions, 3 untested error paths, 2 dead branches deleted
**Ignored (with reasons):** 9 equivalent mutants, log message literals excluded
**Remaining survivors:** 8 in proration rounding — tracked in #812
**CI:** PR runs mutate changed files; break threshold 75 on src/billing About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.