Testing No. 136

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.

  • namemutation-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

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.

~/.claude/skills/mutation-testing/SKILL.md

--- name: mutation-testing description: Measures how strong a test suite really is by running mutation testing with Stryker (JavaScript/TypeScript, .NET), mutmut (Python), PIT… license: MIT ---

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

StackToolStart command
JS/TS (Jest, Vitest, Mocha, Karma)StrykerJSnpm init stryker@latest then npx stryker run
C# / .NETStryker.NETdotnet tool install -g dotnet-stryker then dotnet stryker
Python (pytest)mutmutpip install mutmut then mutmut run
Java / Kotlin (JVM)PIT (pitest)Maven: mvn org.pitest:pitest-maven:mutationCoverage (Gradle plugin available)
Rustcargo-mutantscargo 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

StatusMeaningAction
KilledA test failed with the mutantGood
SurvivedTests ran the code and still passedTriage (below)
No coverageNo test executes this codeUntested code: test it or delete it
TimeoutMutant caused an infinite loop/hangUsually counts as killed; check the timeout is sane
Compile/runtime errorMutant invalid (type error)Ignored in score
IgnoredExcluded by config or commentReview 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:

ClassExampleFix
Missing assertiontotal > 100 → total >= 100 survives; no test at exactly 100Add the boundary case
Weak assertionReturn value changed, test only checks “no throw” or toBeDefined()Assert the actual value
Untested branchError path if (!user) throw mutated to if (false) survivesAdd a test for the error path
Side effect uncheckedCall to audit.log() removed, nothing failsAssert the outgoing side effect if it is a requirement
Equivalent mutanti < len → i != len in a loop with no skipping; behavior identicalMark ignored with a reason
Dead or redundant codeMutated defensive check is unreachableDelete the code
Logging/telemetryString literal in a log message mutatedExclude 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 (--mutate with the PR’s changed source files, or Stryker incremental with 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.

esc