by thetestingacademy
Test quality verification with Stryker mutation testing for JavaScript and TypeScript
npx @qaskills/cli add mutation-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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.
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.i < len to i <= len on an array that is never exactly full) produce identical behavior. Mark them ignored explicitly rather than chasing 100%.> → >= mutant is usually one boundary-value assertion in an existing test, not a new test file.npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker init
// stryker.config.json
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "vitest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.d.ts", "!src/generated/**"],
"coverageAnalysis": "perTest",
"reporters": ["html", "clear-text", "progress", "json"],
"thresholds": { "high": 85, "low": 70, "break": 65 },
"incremental": true,
"incrementalFile": ".stryker-tmp/incremental.json",
"timeoutMS": 10000,
"concurrency": 4
}
For Jest projects swap the runner:
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
// stryker.config.json (Jest variant)
{
"testRunner": "jest",
"jest": { "projectType": "custom", "configFile": "jest.config.js" },
"coverageAnalysis": "perTest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"thresholds": { "high": 85, "low": 70, "break": 65 }
}
Run it:
npx stryker run # full run, writes reports/mutation/mutation.html
npx stryker run --mutate src/pricing.ts # scope to one file while fixing survivors
Stryker output:
[Survived] ArithmeticOperator
src/pricing.ts:14:31
- return subtotal * (1 - discountRate);
+ return subtotal / (1 - discountRate);
Ran 3 tests, none failed.
The suite never asserts a discounted price with a nonzero rate. Kill it with a boundary assertion:
// src/pricing.test.ts
import { describe, expect, it } from 'vitest';
import { applyDiscount } from './pricing';
describe('applyDiscount', () => {
it('multiplies subtotal by the inverse discount rate', () => {
// 200 * (1 - 0.25) = 150; the division mutant would return 266.67
expect(applyDiscount(200, 0.25)).toBe(150);
});
it('returns the subtotal unchanged at rate 0', () => {
expect(applyDiscount(99.5, 0)).toBe(99.5);
});
});
Ignore a genuinely equivalent mutant inline instead of lowering the threshold:
// Stryker disable next-line EqualityOperator: loop bound is equivalent for empty input
for (let i = 0; i < items.length; i++) {
pip install mutmut pytest
# pyproject.toml
[tool.mutmut]
paths_to_mutate = ["src/"]
tests_dir = ["tests/"]
also_copy = ["conftest.py"]
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.py
import pytest
from src.pricing import apply_discount
def test_apply_discount_uses_multiplication():
# mutant changed * to /; 200 * 0.75 == 150, 200 / 0.75 == 266.67
assert apply_discount(200, 0.25) == 150
def test_apply_discount_rejects_rate_above_one():
# kills the mutant that removed the validation guard
with pytest.raises(ValueError):
apply_discount(100, 1.5)
Incremental Stryker on pull requests, full run nightly:
# .github/workflows/mutation.yml
name: mutation
on:
pull_request:
paths: ['src/**', 'stryker.config.json']
schedule:
- cron: '0 2 * * *'
jobs:
stryker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # incremental mode diffs against git history
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Restore incremental cache
uses: actions/cache@v4
with:
path: .stryker-tmp/incremental.json
key: stryker-incremental-${{ github.base_ref || 'main' }}
- name: Mutation test (incremental on PRs, full nightly)
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
npx stryker run --incremental
else
npx stryker run --force
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: mutation-report
path: reports/mutation/mutation.html
The thresholds.break: 65 in config makes stryker run exit non-zero below 65% — no extra scripting needed.
coverageAnalysis: "perTest" — Stryker then only runs the tests that cover each mutant, often a 10x speedup..stryker-tmp/incremental.json to CI cache, never to git.timeoutMS explicitly. Mutants that create infinite loops are killed by timeout; the default factor-based timeout misbehaves on very fast suites.mutmut run against a single module first: paths_to_mutate = ["src/billing.py"].*.test.ts, *.spec.ts, migrations, and codegen output in mutate globs.break to make a red build green. The threshold ratchets up, never down; fix the survivors or explicitly ignore equivalents inline.stryker.config.json, stryker.conf.mjs, or [tool.mutmut] section already exists in the repo.- name: Install QA Skills
run: npx @qaskills/cli add mutation-testing10 of 29 agents supported
Build AI agents that write, run, and fix tests. Playwright, LLM evals, and CI in one live cohort.
Use code AITESTER at checkout