Mutation testing for PowerShell. The metric that proves a test would catch a bug, not merely run the line.
Line/branch coverage only tells you a line executed. PSMutant injects small faults
("mutants") into your scripts — flip -eq to -ne, $true to $false, 5 to 6,
drop a -not — and checks whether your Pester suite fails. The percentage it catches
is your mutation score: the share of injected bugs your tests would actually stop.
It's built on PowerShell's own AST (no Python, no external parser — StrykerJS is JS-only), and every mutant runs inside a throwaway sandbox, so your source is never modified, even if a run is interrupted.
To our knowledge, PSMutant is the first mutation-testing module on the PowerShell Gallery.
Install-Module PSMutant -Scope CurrentUserRequires PowerShell 7.2+ and Pester 5+.
Create a config pointing at the pure modules you want to test and the Pester files that
cover them (see examples/psmutant.config.json):
{
"mutate": ["src/MyModule.Transform.ps1"],
"tests": { "src/MyModule.Transform.ps1": ["tests/Transform.Tests.ps1"] },
"coveredLinesOnly": true,
"thresholds": { "high": 85, "low": 70, "break": null },
"reportPath": "reports/ps-mutation.json"
}Run it from your repo root:
Import-Module PSMutant
$result = Invoke-PSMutation -ConfigFile ./psmutant.config.json
"$($result.Score)% ($($result.Killed)/$($result.Total))"
exit $result.ExitCode # 0 unless thresholds.break is set and unmetSurvivors are printed with file:line and the exact source→mutant change — each is a
missing assertion, an equivalent mutant (a change that can't alter behaviour), or dead code.
- Baseline — runs your tests once (must be green) with Pester code coverage over the
mutatefiles, recording which lines actually executed. - Enumerate — parses each file's AST and collects candidates; only those on covered lines are kept (an uncovered mutant is guaranteed to survive and teaches nothing).
- Evaluate — copies the source subtrees into a temp sandbox, splices each mutant into the copy, runs the covering tests in-process, and restores the copy. Tracked source is never touched.
- Score —
killed / total, written to the JSON report.
A loop-condition guard drops any candidate inside a while/for/do condition, so a
flipped comparison can never spin an infinite loop — which is what makes in-process
execution safe and fast.
| Name | Mutation |
|---|---|
BinaryOperator |
-eq↔-ne, -gt↔-le, -lt↔-ge, -and↔-or, +↔-, *↔/ |
BooleanLiteral |
$true↔$false |
NumberLiteral |
N → N+1 |
NegationRemoval |
-not X → X, !X → X |
StringLiteral |
quoted string → '' (off by default — high-volume/low-signal) |
| Key | Meaning |
|---|---|
mutate |
Files to mutate. Pure / I/O-free logic pays off most. |
tests |
Map each mutate file → the Pester file(s) covering it (per-file test scoping). |
operators |
Operator classes to inject (default omits StringLiteral). |
coveredLinesOnly |
Restrict mutants to lines the baseline executed (default true). |
sandboxSubtrees |
Directories copied into the sandbox (default ["tools","test","setup"]; set to your layout, e.g. ["src","tests"]). |
timeoutFactor / timeoutFloorSeconds |
Per-mutant timeout = max(floor, baseline × factor) (defaults 4 / 15). A non-terminating mutant is cut off and counted Killed, so the run never hangs. |
thresholds.break |
null = report-only. A number fails the run (ExitCode 1) below it. |
reportPath |
Where the JSON report is written (relative to -SourceRoot). |
Pure, deterministic logic where a subtle fault is a real bug: transforms, validators, classifiers, SQL-fragment builders. Skip entry points that do live I/O on load — a mutant there is unreachable without real infrastructure.
Treat the score like a ratchet: directional. Don't chase 100% — equivalent mutants and
untested log strings make the last stretch noise. Raise thresholds.break to lock in gains.
Invoke-Pester ./tests # unit tests
Invoke-ScriptAnalyzer -Path ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 # lint
Test-PSComplexity ./src -Recurse # complexity gate (needs PSComplexity)
Invoke-PSMutation -ConfigFile ./psmutant.self.config.json -SourceRoot . # dogfood: PSMutant on itselfEvery one of these runs in the CI test job and blocks the merge on failure:
- Unit tests — the suites under
tests/. - PSScriptAnalyzer — zero Error/Warning findings (
Write-Hostis the one allowed rule). - Complexity — every unit must stay at or under 15 cyclomatic and 15 cognitive,
measured by the sibling module PSComplexity
(
Test-PSComplexity) — a faithful cognitive metric, not a bundled approximation. - Self-mutation — PSMutant mutation-tests itself; the score must stay above the
thresholds.breakfloor inpsmutant.self.config.json.
The two Fortigi modules dogfood each other: PSMutant gates its complexity with PSComplexity,
and PSComplexity gates its test quality with PSMutant. Separately, code-scanning.yml
uploads PSScriptAnalyzer findings to GitHub code scanning.
MIT © Fortigi