Mutation Testing skill for Codex
Find the bugs your green test suite would ship. Codex runs mutation testing on a scoped module, lists surviving mutants with a bundled script for Stryker JSON and PIT XML, and triages each one: missing boundary case, unasserted result, dead code or equivalent mutant. It adds tests that actually kill the mutant, suppresses noise with reasons, and sets a ratcheting threshold for changed code in CI.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- mutation-testing Installs to ~/.agents/skills/mutation-testing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install mutation-testing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project mutation-testing Installs to .agents/skills/mutation-testing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/mutation-testing.zipunzip -o mutation-testing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) mutation-testing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $mutation-testing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Measures how well tests catch real bugs with mutation testing - StrykerJS (Jest/Vitest runners, TypeScript checker, incremental mode), Stryker.NET, mutmut for Python, PIT for Java/Kotlin and cargo-mutants for Rust - then triages surviving mutants into missing assertions, missing cases, dead code or equivalent mutants, and gates CI on changed code. Includes a script that lists survivors from Stryker JSON or PIT XML. Use when coverage looks high but bugs slip through, when assessing test quality of a module, or when adding a mutation score gate.
Use it when
- Coverage is high but defects still escape
- Judging the strength of tests for a critical module
- Adding a mutation score gate to CI
- Reviewing whether new tests assert anything meaningful
Use something else when
- The suite is flaky or slow and needs fixing first: use flaky-test-triage
- You only need coverage tooling and thresholds: use coverage-analysis
How to check the result
Each addressed mutant is reported as Killed on rerun or ignored with a written reason, and the new tests fail when the mutation is applied by hand.
The mistake it prevents
Mutating the whole repository on a slow or flaky suite, producing a run that takes hours and a score nobody trusts.
The goal is not a score; it is a list of concrete weaknesses in the tests, each fixed by a targeted assertion or case, or consciously accepted. A surviving mutant is a bug the current tests would have shipped.
Inspect first
- Read
AGENTS.mdfor the test command. Mutation tools rerun the suite hundreds of times; it must be green, deterministic and reasonably fast first. - Measure one full test run. If it takes more than a few minutes, scope mutation to one module or to changed files, never the whole repo at once.
- Check for an existing config (
stryker.config.*,[tool.mutmut]inpyproject.toml,pitestin the build file,.cargo/mutants.toml). - Installing these tools needs network access; ask for approval in a sandboxed session.
Pick the tool
| Stack | Tool | Notes |
|---|---|---|
| JavaScript / TypeScript | StrykerJS | runners for Jest, Vitest, Mocha, Karma; TypeScript checker removes uncompilable mutants |
| C# | Stryker.NET (dotnet stryker) | can limit to changes since a branch |
| Python | mutmut 3 | uses fork; on Windows run it under WSL |
| Java / Kotlin (JVM) | PIT (pitest) | needs pitest-junit5-plugin for JUnit 5 |
| Rust | cargo-mutants | no config needed to start |
| Go | gremlins | less mature; scope to one package |
Workflow
- [ ] 1. Confirm a green, deterministic test run and record its duration
- [ ] 2. Configure the tool for one module or the changed files
- [ ] 3. Run and produce a machine-readable report
- [ ] 4. List survivors and triage each one
- [ ] 5. Add or strengthen tests; rerun only the affected scope
- [ ] 6. Decide on a threshold and CI scope
2-3. Configure and run
StrykerJS (npm init stryker@latest scaffolds a config interactively):
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "vitest",
"checkers": ["typescript"],
"tsconfigFile": "tsconfig.json",
"mutate": ["src/pricing/**/*.ts", "!src/**/*.test.ts"],
"coverageAnalysis": "perTest",
"incremental": true,
"reporters": ["clear-text", "progress", "html", "json"],
"thresholds": { "high": 85, "low": 70, "break": 60 }
}
npx stryker run # report in reports/mutation/
npx stryker run --mutate "src/pricing/discount.ts"
incremental reuses results for unchanged code and tests between runs, which makes
reruns during triage cheap.
mutmut:
[tool.mutmut]
paths_to_mutate = ["src/billing/"]
mutmut run
mutmut results # lists surviving mutant names
mutmut show <mutant-name> # diff of one mutant
PIT with Maven (plugin configured in pom.xml with targetClasses, the JUnit 5 plugin
dependency, outputFormats XML and HTML, and withHistory for incremental runs):
mvn test-compile org.pitest:pitest-maven:mutationCoverage
# Gradle with the info.solidsoft.pitest plugin:
./gradlew pitest
cargo-mutants:
cargo mutants -f src/pricing.rs
git diff origin/main > /tmp/pr.diff && cargo mutants --in-diff /tmp/pr.diff
4. List survivors
For Stryker (json reporter) or PIT (XML output), summarize survivors by file:
python3 scripts/survivors.py reports/mutation/mutation.json
python3 scripts/survivors.py target/pit-reports/mutations.xml --include-no-coverage
python3 scripts/survivors.py reports/mutation/mutation.json --min-score 70 # exit 1 below 70%
The score counts Killed and Timeout as detected, Survived and NoCoverage as undetected, and ignores compile and runtime errors. For mutmut and cargo-mutants, read their own result commands or output folders.
Triage each survivor
| What the mutant shows | Meaning | Action |
|---|---|---|
>= became > and survived | boundary not tested | add the exact boundary case |
| Return value replaced, call result ignored | result never asserted | assert on the output, not just “no throw” |
| Removed a call with a side effect (save, publish) | side effect unverified | assert on state or the outbound call |
| Whole block removed, NoCoverage | no test reaches it | add a test, or delete the code if unreachable |
| String literal emptied in a log message | irrelevant to behavior | exclude logging from mutation |
Change cannot alter observable behavior (i < len vs i != len in a loop) | equivalent mutant | mark ignored with a reason |
| Mutant survives because a timeout or retry masks it | tests too lenient on timing | tighten the test with fake timers |
Write a test that kills the mutant, then confirm by rerunning just that file. A new test that passes against the mutant is not done.
Suppress deliberately, with a reason, using the tool’s mechanism:
// Stryker disable next-line StringLiteral: log wording is not behavior
logger.info("discount applied");
log.debug("cache miss %s", key) # pragma: no mutate
6. Threshold and CI
- Run mutation on changed files in PRs (Stryker
--mutatewith the changed paths or incremental mode, Stryker.NET’s since-branch option, PIT’s SCM goal, cargo-mutants--in-diff) and on full modules nightly. - Set
breaka few points below the current score of the module, then ratchet up. A repo-wide 80% target imposed on day one gets disabled within a week. - Exclude generated code, DTOs, migrations and logging-only modules from
mutate. - Report the score next to line coverage; a large gap (coverage 95%, score 55%) is the most useful finding for the team.
Gotchas
- Flaky tests make mutants look killed or survived at random; fix flakiness first.
coverageAnalysis: "off"runs every test against every mutant; keepperTestunless tests share hidden global state, in which case fix that state.- Mutants that hang are counted as Timeout (detected), which can inflate scores for code with infinite-loop-prone mutations; that is acceptable, not a test strength.
- Static initializers and module-level constants are evaluated once; Stryker can
ignore static mutants (
ignoreStatic) when they make runs slow. - Do not chase 100%: equivalent mutants exist, and tests written only to kill trivial mutants (log text) add maintenance with no safety.
Verification
- Rerun the tool on the triaged scope: each mutant you addressed is now Killed or explicitly ignored with a reason.
- The new tests pass on the real code and fail when the mutation is applied by hand.
- The full test suite is still green and not noticeably slower.
Output
**Scope:** src/pricing (412 mutants, 3 m 40 s with incremental)
**Score:** 61% -> 84% (line coverage unchanged at 93%)
**Fixed:** 9 boundary cases (tier thresholds), 4 unasserted return values, 2 untested error paths
**Accepted:** 6 equivalent mutants (loop bounds), ignored with reasons
**Removed:** dead branch in legacyRounding() (NoCoverage, unreachable)
**CI:** Stryker on changed files in PRs, break at 75 for src/pricing What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Mutation Testing"
short_description: "Find the bugs your green test suite would ship"
default_prompt: "$mutation-testing Our coverage is 90% but bugs still get through. Run mutation testing on the pricing module" scripts/survivors.py
#!/usr/bin/env python3
"""List surviving mutants from a mutation testing report, grouped by file.
Supports:
* mutation-testing-elements JSON (Stryker JS/.NET "json" reporter,
default path reports/mutation/mutation.json, and other tools emitting that schema)
* PIT mutations.xml (outputFormats XML)
Usage:
python3 survivors.py <report> [--min-score N] [--limit N] [--include-no-coverage]
Exit codes: 0 = ok (score >= min-score), 1 = score below --min-score, 2 = bad input.
"""
from __future__ import annotations
import argparse
import json
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
DETECTED = {"Killed", "Timeout"}
UNDETECTED = {"Survived", "NoCoverage"}
# Statuses excluded from the score: CompileError, RuntimeError, Ignored, Pending.
def load_mte(data: dict) -> list[dict]:
mutants = []
for path, f in (data.get("files") or {}).items():
source_lines = (f.get("source") or "").splitlines()
for m in f.get("mutants", []):
line = m.get("location", {}).get("start", {}).get("line", 0)
original = source_lines[line - 1].strip() if 0 < line <= len(source_lines) else ""
mutants.append({
"file": path,
"line": line,
"mutator": m.get("mutatorName", "?"),
"detail": m.get("replacement") or m.get("description") or "",
"original": original,
"status": m.get("status", "?"),
})
return mutants
PIT_STATUS = {
"KILLED": "Killed", "TIMED_OUT": "Timeout", "SURVIVED": "Survived",
"NO_COVERAGE": "NoCoverage", "MEMORY_ERROR": "RuntimeError",
"RUN_ERROR": "RuntimeError", "NON_VIABLE": "CompileError",
}
def load_pit(root: ET.Element) -> list[dict]:
mutants = []
for m in root.iter("mutation"):
cls = m.findtext("mutatedClass", "")
pkg_path = cls.rsplit(".", 1)[0].replace(".", "/") if "." in cls else ""
src = m.findtext("sourceFile", "?")
mutants.append({
"file": f"{pkg_path}/{src}" if pkg_path else src,
"line": int(m.findtext("lineNumber", "0") or 0),
"mutator": (m.findtext("mutator", "?") or "?").rsplit(".", 1)[-1],
"detail": m.findtext("description", "") or "",
"original": f"in {m.findtext('mutatedMethod', '?')}()",
"status": PIT_STATUS.get(m.get("status", ""), m.get("status", "?")),
})
return mutants
def load(path: Path) -> list[dict]:
text = path.read_text(encoding="utf-8")
stripped = text.lstrip()
if stripped.startswith("{"):
return load_mte(json.loads(text))
if stripped.startswith("<"):
return load_pit(ET.fromstring(text))
raise ValueError("unrecognized report format (expected JSON or PIT XML)")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("report", type=Path)
ap.add_argument("--min-score", type=float, default=None,
help="fail (exit 1) if the mutation score is below this percentage")
ap.add_argument("--limit", type=int, default=50, help="max survivors to print (default 50)")
ap.add_argument("--include-no-coverage", action="store_true",
help="also list NoCoverage mutants (otherwise only counted)")
args = ap.parse_args()
try:
mutants = load(args.report)
except (OSError, ValueError, ET.ParseError) as exc:
print(f"error: cannot read {args.report}: {exc}", file=sys.stderr)
return 2
counts: dict[str, int] = defaultdict(int)
for m in mutants:
counts[m["status"]] += 1
detected = sum(counts[s] for s in DETECTED)
undetected = sum(counts[s] for s in UNDETECTED)
valid = detected + undetected
score = 100.0 * detected / valid if valid else 100.0
print(f"mutants: {len(mutants)} " + " ".join(f"{k}={v}" for k, v in sorted(counts.items()) if v))
print(f"mutation score: {score:.1f}% ({detected}/{valid} detected)")
wanted = {"Survived"} | ({"NoCoverage"} if args.include_no_coverage else set())
by_file: dict[str, list[dict]] = defaultdict(list)
for m in mutants:
if m["status"] in wanted:
by_file[m["file"]].append(m)
shown = 0
for file in sorted(by_file, key=lambda f: -len(by_file[f])):
if shown >= args.limit:
break
items = sorted(by_file[file], key=lambda m: m["line"])
print(f"\n{file} ({len(items)} undetected)")
for m in items:
if shown >= args.limit:
break
tag = "" if m["status"] == "Survived" else " [no coverage]"
detail = f" -> {m['detail']}" if m["detail"] else ""
ctx = f" | {m['original']}" if m["original"] else ""
print(f" L{m['line']:<5} {m['mutator']}{detail}{tag}{ctx}")
shown += 1
if shown >= args.limit:
print(f"\n(output limited to {args.limit}; use --limit to see more)")
if args.min_score is not None and score < args.min_score:
print(f"\nFAIL: score {score:.1f}% is below --min-score {args.min_score}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.