| name | Mutation Testing |
| description | Run mutation testing with StrykerJS for TypeScript/JavaScript and mutmut for Python to measure whether your test suite actually catches bugs, enforce mutation score thresholds, and keep runs fast with incremental mode in CI. |
| version | 1.0.0 |
| author | thetestingacademy |
| license | MIT |
| tags | ["mutation-testing","stryker","mutmut","test-quality","coverage","code-quality","ci","jest","vitest","pytest"] |
| testingTypes | ["unit","code-quality"] |
| frameworks | ["jest","vitest","pytest"] |
| languages | ["typescript","javascript","python"] |
| domains | ["web","api"] |
| agents | ["claude-code","cursor","github-copilot","windsurf","codex","aider","continue","cline","zed","bolt","gemini-cli","amp"] |
Mutation Testing
This skill makes an AI agent set up and interpret mutation testing: StrykerJS for JS/TS projects (Jest or Vitest runners) and mutmut for Python. Mutation testing injects small bugs (mutants) into source code and re-runs the tests — if the tests still pass, the mutant "survived" and your suite has a blind spot that line coverage never showed. Trigger this when a team claims high coverage but still ships regressions, when reviewing test suite quality, or when the user mentions Stryker, mutmut, or mutation score.
Core Principles
- Line coverage measures execution, mutation score measures verification. A test that calls a function and asserts nothing gives 100% line coverage and a 0% mutation score. Mutation score is the honest metric.
- Never mutate everything on every run. A full mutation run on a real codebase takes hours. Use incremental mode locally and scope CI runs to changed files. Full runs belong in nightly jobs.
- A surviving mutant is either a missing assertion or dead code. Both are findings. If you cannot write a test that kills a mutant, the mutated code path is unreachable — delete it instead of suppressing the mutant.
- Set
break thresholds, not aspirational targets. Start at your current score minus 2, fail the build below it, and ratchet up monthly. A threshold nobody enforces is documentation fiction.
- Equivalent mutants exist; budget for them. Some mutations (e.g.,
i < len to i <= len on an array that is never exactly full) produce identical behavior. Mark them ignored explicitly rather than chasing 100%.
- Kill mutants with stronger assertions, not more tests. The fix for a survived
> → >= mutant is usually one boundary-value assertion in an existing test, not a new test file.
Setup: StrykerJS (TypeScript + Vitest)
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker init
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "vitest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.d.ts", "!src/generated/**"],
"coverageAnalysis": "perTest",
"reporters": ["html", "clear-text", "progress", "json"],
"thresholds": { "high": 85, "low": 70, "break": 65 },
"incremental": true,
"incrementalFile": ".stryker-tmp/incremental.json",
"timeoutMS": 10000,
"concurrency": 4
}
For Jest projects swap the runner:
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
{
"testRunner": "jest",
"jest": { "projectType": "custom", "configFile": "jest.config.js" },
"coverageAnalysis": "perTest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"thresholds": { "high": 85, "low": 70, "break": 65 }
}
Run it:
npx stryker run
npx stryker run --mutate src/pricing.ts
Reading a Survived Mutant and Killing It
Stryker output:
[Survived] ArithmeticOperator
src/pricing.ts:14:31
- return subtotal * (1 - discountRate);
+ return subtotal / (1 - discountRate);
Ran 3 tests, none failed.
The suite never asserts a discounted price with a nonzero rate. Kill it with a boundary assertion:
import { describe, expect, it } from 'vitest';
import { applyDiscount } from './pricing';
describe('applyDiscount', () => {
it('multiplies subtotal by the inverse discount rate', () => {
expect(applyDiscount(200, 0.25)).toBe(150);
});
it('returns the subtotal unchanged at rate 0', () => {
expect(applyDiscount(99.5, 0)).toBe(99.5);
});
});
Ignore a genuinely equivalent mutant inline instead of lowering the threshold:
for (let i = 0; i < items.length; i++) {
Setup: mutmut (Python + pytest)
pip install mutmut pytest
[tool.mutmut]
paths_to_mutate = ["src/"]
tests_dir = ["tests/"]
also_copy = ["conftest.py"]
mutmut run
mutmut results
mutmut show src.pricing.apply_discount__mutmut_3
mutmut run --max-children 4
Killing a Python survivor follows the same pattern — the mutant tells you the missing assertion:
import pytest
from src.pricing import apply_discount
def test_apply_discount_uses_multiplication():
assert apply_discount(200, 0.25) == 150
def test_apply_discount_rejects_rate_above_one():
with pytest.raises(ValueError):
apply_discount(100, 1.5)
CI Integration
Incremental Stryker on pull requests, full run nightly:
name: mutation
on:
pull_request:
paths: ['src/**', 'stryker.config.json']
schedule:
- cron: '0 2 * * *'
jobs:
stryker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Restore incremental cache
uses: actions/cache@v4
with:
path: .stryker-tmp/incremental.json
key: stryker-incremental-${{ github.base_ref || 'main' }}
- name: Mutation test (incremental on PRs, full nightly)
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
npx stryker run --incremental
else
npx stryker run --force
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: mutation-report
path: reports/mutation/mutation.html
The thresholds.break: 65 in config makes stryker run exit non-zero below 65% — no extra scripting needed.
Best Practices
- Start mutation testing on your most critical module (pricing, auth, parsers), not the whole repo. One high-signal report beats a week-long full run.
- Use
coverageAnalysis: "perTest" — Stryker then only runs the tests that cover each mutant, often a 10x speedup.
- Commit
.stryker-tmp/incremental.json to CI cache, never to git.
- Track mutation score per module in the JSON report; aggregate scores hide a 30% module behind a 90% repo average.
- Set
timeoutMS explicitly. Mutants that create infinite loops are killed by timeout; the default factor-based timeout misbehaves on very fast suites.
- In Python, run
mutmut run against a single module first: paths_to_mutate = ["src/billing.py"].
Anti-Patterns
- Chasing 100% mutation score. Past ~90% you are writing tests for equivalent mutants. Mark them ignored with a reason and move on.
- Running full mutation on every PR. It will take 40+ minutes and the team will delete the workflow within a month. Incremental on PRs, full nightly.
- Treating "survived" as a Stryker bug. Over years of use, genuine false positives are rare; assume your suite is the problem first.
- Mutating test files or generated code. Always exclude
*.test.ts, *.spec.ts, migrations, and codegen output in mutate globs.
- Lowering
break to make a red build green. The threshold ratchets up, never down; fix the survivors or explicitly ignore equivalents inline.
- Writing assertion-free "kill tests" that snapshot huge objects just to kill mutants. A surviving mutant deserves a precise boundary assertion, not a snapshot blanket.
When to Trigger This Skill
- The user mentions Stryker, mutmut, mutation score, or "are my tests actually good".
- A project has high line coverage but recurring production regressions in covered code.
- Reviewing or hardening a test suite for a critical module (payments, auth, data migration).
- Setting up CI quality gates beyond line coverage thresholds.
- A
stryker.config.json, stryker.conf.mjs, or [tool.mutmut] section already exists in the repo.