mutation-testing · git:20260806.b506b65 · 2026-08-06 · sha256 9dee3eba58c5ee81

mutation-testing git:20260806.b506b65A

Immutable. This exact content never changes and is served at /api/v1/blob/9dee3eba58c5ee81.

---
name: mutation-testing
description: Use when entering COMMIT & VERIFY phase, killing surviving mutants, verifying test quality via mutation score, or analyzing Stryker reports after the test baseline is green
---

# Mutation Testing

Verify that tests **actually catch bugs** — not just execute code.

## Core Rule

A test that kills no mutant is noise. DELETE IT.

## When to Load

- Entering phase 4 (COMMIT & VERIFY) of the TDD cycle.
- Investigating a surviving mutant.
- Confirming a kill after writing a boundary test.

**Never run on a red baseline** — fix tests first.

## S7 — Deterministic Execution (Non-Negotiable)

Mutation testing MUST be executed via terminal tool calls. Do NOT assert
results from prose. The flow is:

```
1. runInTerminal → dotnet stryker (with correct args)
2. Parse JSON output → extract survivors
3. Decide: kill (write test) or document (equivalent mutant)
4. Re-run scoped stryker → confirm kill
```

## Step 1: Run Stryker (via terminal)

### Detect project paths first

Before running, identify:
- `--project` : the production `.csproj` being mutated (Domain or Application)
- `-tp` : the test `.csproj` that exercises it

### During development (fast — changed code only)

```bash
dotnet stryker \
  --project <Production.csproj> \
  -tp <Tests.csproj> \
  --since:main \
  --break-at 100 \
  --reporter json --reporter cleartext
```

### Before merge (full business logic)

```bash
dotnet stryker \
  --project <Production.csproj> \
  -tp <Tests.csproj> \
  --mutate "**/*.cs" \
  --mutate "!**/*Marker.cs" \
  --mutate "!**/DependencyInjection.cs" \
  --mutate "!**/obj/**" \
  --break-at 100 \
  --threshold-high 90 --threshold-low 80 \
  --reporter json --reporter cleartext
```

### Tool availability check

```bash
dotnet stryker --version
# If fails: dotnet tool install -g dotnet-stryker
```

### Frontend (`npx stryker run`) — different flag syntax

⚠️ **`dotnet stryker` and `npx stryker run` do NOT share the same reporter
flag.** Do not copy `-r`/`--reporter` between stacks:

| Stack | Flag | Syntax |
|-------|------|--------|
| `dotnet stryker` (.NET) | `-r` / `--reporter` | repeatable, one value each: `-r json -r cleartext` |
| `npx stryker run` (JS/TS) | `--reporters` | single flag, comma-separated: `--reporters clear-text,json` |

```bash
npx stryker run --reporters clear-text,json --mutate "src/path/to/changed-file.ts"
```

The frontend JSON reporter always writes to the **same fixed path**
(`reports/mutation/mutation.json`), overwriting it on every run — unlike
`dotnet stryker`, which creates a fresh timestamped `StrykerOutput/<run>/`
directory each time. **Before parsing `reports/mutation/mutation.json`,
verify it was written by the run you just triggered** (e.g. compare its
mtime to the time the command started), otherwise a scoped run may be
analyzed against a stale, wider report from a previous full run:

```bash
date +%s > /tmp/run-start.txt
npx stryker run --reporters clear-text,json --mutate "src/path/to/changed-file.ts"
node -e '
  const fs = require("fs");
  const start = Number(fs.readFileSync("/tmp/run-start.txt", "utf8").trim());
  const mtime = fs.statSync("reports/mutation/mutation.json").mtimeMs / 1000;
  if (mtime < start) { console.error("STALE REPORT — do not parse"); process.exit(1); }
'
```

For scoped/fast runs, prefer parsing the `clear-text` reporter's stdout
output directly instead of the JSON file — it always reflects the current
run and avoids the staleness risk entirely.

## Step 2: Parse Results (via terminal)

Extract survivors from the JSON report:

```bash
jq '[.files | to_entries[] | {file: .key, survivors: [.value.mutants[] | select(.status == "Survived") | {mutator: .mutatorName, line: .location.start.line, replacement: .replacement}]}] | map(select(.survivors | length > 0))' \
  StrykerOutput/$(ls -t StrykerOutput | head -1)/reports/mutation-report.json
```

If `jq` is unavailable, or you are running a scoped frontend run, use the
`cleartext`/`clear-text` reporter output directly.

## Step 3: Classify Survivors

| Category | Action |
|----------|--------|
| **Real gap** — behavior change not caught | Write a boundary test to kill it |
| **Equivalent mutant** — no observable difference | Document in code comment + accept |

### Equivalent mutant examples (do NOT write tests for these)

- Removed a log statement (no observable effect)
- Changed dead code path
- Defensive null check when type guarantees non-null
- Arithmetic on unused intermediate variable

## Step 4: Kill Real Survivors

For each real survivor:

1. **Read the mutation** — what operator changed? what line?
2. **Write ONE boundary test** targeting the exact edge:
   ```csharp
   // Survivor: `age >= 18` → `age > 18`
   [Fact]
   public void WhenDriverIsExactly18_ShouldBeEligible()
   {
       // ... test the boundary value age=18
   }
   ```
3. **Re-run scoped Stryker** to confirm the kill:
   ```bash
   dotnet stryker \
     --project <Production.csproj> \
     -tp <Tests.csproj> \
     --mutate "**/<FileWithSurvivor>.cs" \
     --break-at 100 \
     --reporter cleartext
   ```

## Step 5: Gate Decision

| Mutation score | Verdict |
|---------------|---------|
| 100% on business logic | ✅ Proceed to commit |
| < 100% with only equivalent mutants documented | ✅ Proceed |
| < 100% with real survivors | ❌ BLOCK — return to Step 4 |

## Mutation Categories Reference

| Category | Examples |
|----------|----------|
| Arithmetic | `+` ↔ `-`, `*` ↔ `/` |
| Comparison | `>` ↔ `>=`, `<` ↔ `<=`, `==` ↔ `!=` |
| Boolean | `true` ↔ `false`, `&&` ↔ `||` |
| Conditional | negate conditions, remove `if` branch |
| Return value | `return true` → `return false` |
| LINQ | `.Any()` ↔ `.All()`, `.First()` ↔ `.Last()` |

## Scope Exclusions

Never mutate:
- DTOs, ViewModels, passive data structures
- Infrastructure adapters (tested via integration tests)
- `DependencyInjection.cs`, `Program.cs`, config
- Marker interfaces, generated code