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.

  • Testing & QA
  • $mutation-testing
  • 179 lines
  • 3 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- mutation-testing

Installs to ~/.agents/skills/mutation-testing/ for every project on this machine.

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

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.

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

--- name: mutation-testing description: Measures how well tests catch real bugs with mutation testing - StrykerJS (Jest/Vitest runners, TypeScript checker, incremental mode), Stryker.NET,… license: MIT ---

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

  1. Read AGENTS.md for the test command. Mutation tools rerun the suite hundreds of times; it must be green, deterministic and reasonably fast first.
  2. 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.
  3. Check for an existing config (stryker.config.*, [tool.mutmut] in pyproject.toml, pitest in the build file, .cargo/mutants.toml).
  4. Installing these tools needs network access; ask for approval in a sandboxed session.

Pick the tool

StackToolNotes
JavaScript / TypeScriptStrykerJSrunners for Jest, Vitest, Mocha, Karma; TypeScript checker removes uncompilable mutants
C#Stryker.NET (dotnet stryker)can limit to changes since a branch
Pythonmutmut 3uses fork; on Windows run it under WSL
Java / Kotlin (JVM)PIT (pitest)needs pitest-junit5-plugin for JUnit 5
Rustcargo-mutantsno config needed to start
Gogremlinsless 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 showsMeaningAction
>= became > and survivedboundary not testedadd the exact boundary case
Return value replaced, call result ignoredresult never assertedassert on the output, not just “no throw”
Removed a call with a side effect (save, publish)side effect unverifiedassert on state or the outbound call
Whole block removed, NoCoverageno test reaches itadd a test, or delete the code if unreachable
String literal emptied in a log messageirrelevant to behaviorexclude logging from mutation
Change cannot alter observable behavior (i < len vs i != len in a loop)equivalent mutantmark ignored with a reason
Mutant survives because a timeout or retry masks ittests too lenient on timingtighten 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 --mutate with 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 break a 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; keep perTest unless 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

  1. Rerun the tool on the triaged scope: each mutant you addressed is now Killed or explicitly ignored with a reason.
  2. The new tests pass on the real code and fail when the mutation is applied by hand.
  3. 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.yamlgenerated from this page

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.py137 lines, 5.1 KB
#!/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.

esc