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.
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.
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.
mutmut run # mutates src/, runs pytest per mutant, caches results
mutmut results # summary: killed / survived / timeout / suspicious
mutmut show src.pricing.apply_discount__mutmut_3 # diff of one survivor
mutmut run --max-children 4 # parallel workers
Killing a Python survivor follows the same pattern — the mutant tells you the missing assertion:
# tests/test_pricing.pyimport pytest
from src.pricing import apply_discount
deftest_apply_discount_uses_multiplication():
# mutant changed * to /; 200 * 0.75 == 150, 200 / 0.75 == 266.67assert apply_discount(200, 0.25) == 150deftest_apply_discount_rejects_rate_above_one():
# kills the mutant that removed the validation guardwith pytest.raises(ValueError):
apply_discount(100, 1.5)
CI Integration
Incremental Stryker on pull requests, full run nightly:
# .github/workflows/mutation.ymlname:mutationon:pull_request:paths: ['src/**', 'stryker.config.json']
schedule:-cron:'0 2 * * *'jobs:stryker:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4with:fetch-depth:0# incremental mode diffs against git history-uses:actions/setup-node@v4with:node-version:20cache:npm-run:npmci-name:Restoreincrementalcacheuses:actions/cache@v4with:path:.stryker-tmp/incremental.jsonkey:stryker-incremental-${{github.base_ref||'main'}}-name:Mutationtest(incrementalonPRs,fullnightly)run:|
if [ "${{ github.event_name }}" = "pull_request" ]; then
npx stryker run --incremental
else
npx stryker run --force
fi
-uses:actions/upload-artifact@v4if:always()with:name:mutation-reportpath: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.