Running Stryker Mutation Testing With Vitest
The first Stryker run is where most teams decide whether mutation testing is a useful tool or an expensive curiosity, and the deciding factor is almost always scope. Pointed at a whole repository with default settings, Stryker generates tens of thousands of mutants, runs for hours, and produces a report nobody reads. Pointed at one directory of business logic with per-test coverage analysis, the same tool finishes in under two minutes and hands you a list of genuinely weak tests. This guide walks a Vitest project from nothing to a trustworthy first report: installing the runner, writing a configuration that is deliberately narrow, interpreting what comes back, and confirming the tool is measuring what you think it is. It assumes Vitest 1.x or 2.x, Node 20 or newer, and a suite that is already green, and it sits under mutation testing and assertion quality.
Root Cause Analysis
Slow first runs come from three compounding settings, and understanding each makes the configuration below obvious rather than magical. The first is the mutation glob. Stryker generates a mutant for every operator, literal, conditional and statement in scope, so including a UI directory, a generated API client or a migrations folder can multiply the mutant count tenfold while adding almost nothing you would act on.
The second is coverage analysis. By default Stryker can be configured to run the full test suite for every single mutant, which is quadratic in the worst sense: suite time multiplied by mutant count. Per-test coverage analysis instruments one initial run to learn which tests touch which lines, then runs only the covering tests for each mutant. On a typical unit suite that is an order of magnitude less work.
The third is the sandbox. Stryker copies your project into a temporary directory for each concurrent runner, so a repository carrying a large .next, dist or coverage directory pays that copy repeatedly before a single mutant runs. Excluding build output is a one-line change that often halves startup.
Reproducible Setup
Start with a project whose tests already pass, since Stryker’s baseline run will refuse to continue against a red suite.
npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner
npx vitest run # must be green before continuing
// src/domain/pricing.ts — the logic we want measured
export type Cart = { total: number; items: number; memberSince?: Date };
export function applyDiscount(cart: Cart, threshold: number, rate = 0.1) {
if (cart.total > threshold) {
return { ...cart, discount: Number((cart.total * rate).toFixed(2)) };
}
return { ...cart, discount: 0 };
}
// src/domain/pricing.test.ts — a test that covers the line but asserts weakly
import { test, expect } from 'vitest';
import { applyDiscount } from './pricing';
test('returns a cart', () => {
const result = applyDiscount({ total: 150, items: 2 }, 100);
expect(result).toBeDefined();
});
That test gives the file complete line coverage and will not notice if > becomes >=, if the rate changes, or if the discount is returned as zero. It is exactly the kind of test mutation testing exists to find.
Implementation
Step 1 — Write a deliberately narrow configuration. Name the directory you want measured, exclude tests and type declarations, and turn on per-test analysis from the start.
// stryker.config.mjs
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
packageManager: 'npm',
testRunner: 'vitest',
reporters: ['html', 'clear-text', 'progress'],
coverageAnalysis: 'perTest',
mutate: ['src/domain/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts'],
ignorePatterns: ['dist', 'coverage', '.next', 'node_modules/.cache'],
concurrency: 4,
timeoutMS: 10_000,
htmlReporter: { fileName: 'reports/mutation/index.html' },
};
Step 2 — Point the Vitest runner at the right configuration file. If your project has several Vitest configurations — common in a workspace — name the one that runs the unit tests, or Stryker will pick up browser or end-to-end projects and time out.
// stryker.config.mjs (addition)
export default {
// …
vitest: { configFile: 'vitest.unit.config.ts' },
};
Step 3 — Run it and read the three counts. The first run performs an instrumented baseline pass, then the mutants. The clear-text reporter prints survivors with their file, line and mutator name.
npx stryker run
# Mutation testing ████████████████████ 100% (elapsed ~1m)
# Mutation score: 42.86%
# killed 12
# survived 16
# no coverage 0
# src/domain/pricing.ts:8:7 ConditionalExpression survived
# src/domain/pricing.ts:9:44 ArithmeticOperator survived
Step 4 — Open the HTML report and work one file at a time. The report renders your source with each mutant inline, colour-coded by status. Clicking a survivor shows the exact replacement Stryker made, which converts an abstract score into a specific question about a specific line.
npx serve reports/mutation # or open reports/mutation/index.html directly
Step 5 — Kill the survivors by asserting on behaviour. Replace the weak test with cases that pin the boundary and the arithmetic. The count of tests barely changes; what changes is whether they would notice a defect.
// src/domain/pricing.test.ts
import { test, expect, describe } from 'vitest';
import { applyDiscount } from './pricing';
describe('applyDiscount', () => {
test.each([
[99, 0],
[100, 0], // pins the boundary: > not >=
[101, 10.1], // pins the rate
])('total %i yields discount %f', (total, expected) => {
expect(applyDiscount({ total, items: 1 }, 100).discount).toBeCloseTo(expected);
});
test('leaves the rest of the cart untouched', () => {
const cart = { total: 200, items: 3 };
expect(applyDiscount(cart, 100)).toMatchObject({ total: 200, items: 3 });
});
});
Verification
Re-run over the same file and confirm the score moved for the reason you expect. A targeted run finishes in seconds and keeps the feedback loop tight while you strengthen tests.
npx stryker run --mutate "src/domain/pricing.ts"
# Mutation score: 100.00%
# killed 28
# survived 0
Then verify the tool itself by re-introducing a weakness. Comment out the boundary case, run again, and check that exactly the conditional mutant survives. If it does not, your configuration is not measuring the file you think it is — usually a glob or a Vitest configuration pointing elsewhere.
npx stryker run --mutate "src/domain/pricing.ts"
# Mutation score: 96.43%
# src/domain/pricing.ts:8:7 ConditionalExpression survived ← the expected survivor
Finally, confirm the run is repeatable. Two consecutive runs on an unchanged tree must report the same score; drift means tests are order-dependent, which per-test analysis will amplify rather than cause.
One further check is worth doing once per project: compare the mutation report against the coverage report for the same directory. Files with high coverage and low mutation score are the ones where the suite is executing code without checking it, and they are the highest-value place to spend an afternoon. Files with low coverage and low mutation score need tests at all, which is a different and usually easier job.
Troubleshooting
Symptom: “Initial test run failed” though vitest run is green. Diagnosis: Stryker runs in a sandbox copy, so anything resolved by absolute path, read from an untracked file, or provided by a globally-installed binary is missing there. Fix: make setup files relative, add required fixtures to the repository, and check ignorePatterns is not excluding something the tests genuinely need.
Symptom: every mutant is reported as a timeout. Diagnosis: timeoutMS is below the real cost of your slowest covering test, so Stryker gives up before the test can fail. Fix: raise it to roughly three times your slowest unit test, and if the slowest test is seconds long, move it out of the mutated scope — it is an integration test in a unit test’s clothing.
Symptom: the score is suspiciously high on the first run. Diagnosis: snapshot assertions are killing mutants indiscriminately, or type checking is left on so mutants fail to compile and count as killed. Fix: confirm disableTypeChecks is enabled for the sandbox, and read a few killed mutants in the HTML report to see whether a meaningful assertion or a blanket snapshot did the work.
FAQ
How long should a first run take?
On a scoped glob with per-test analysis, a few hundred to a couple of thousand mutants over a fast unit suite should finish in one to three minutes on four concurrent runners. If your first run is heading past ten minutes, stop it and narrow the glob rather than waiting — the report will be no more useful for having covered more files, and a fast loop is what makes fixing survivors pleasant.
Does Stryker work with Vitest workspaces?
Yes, but point it at a single configuration with the vitest.configFile option rather than the workspace root. A workspace configuration typically includes browser-mode and integration projects whose startup cost per mutant is prohibitive. Run Stryker per package, with each package naming its own unit configuration, which also keeps reports attributable.
Should the mutation score gate merges?
Not on the first day. Run it nightly until you know the natural floor for each area of the codebase, then set thresholds.break slightly below that floor so it catches regressions without blocking unrelated work. Setting a break threshold from a single first report reliably produces either a meaningless gate or an angry team.
What about code that talks to a database or network?
Exclude it. Mutation testing is at its best on pure functions where a small change produces a different answer; on I/O-bound adapters, mutants either fail instantly for uninteresting reasons or survive because the test mocks everything. Keep the glob on domain logic and let adapters be covered by the integration tier instead.
Related
- Back to Mutation Testing & Assertion Quality
- Finding weak assertions with mutation scores — what to do with the survivor list.
- Keeping mutation testing fast in CI — incremental runs and scoping for a scheduled job.
- Why 100% coverage is the wrong target — the metric this technique corrects.