Guide / Test quality

Mutation testing in TypeScript and JavaScript

Short answer

Mutation testing checks whether your tests actually catch bugs. A tool makes small deliberate changes to your code, such as turning < into <= or deleting a guard, then reruns the tests. If a test fails, the mutant is killed. If every test still passes, that behavior is not really tested, whatever your coverage report says.

Why 100% coverage can still mean untested

Coverage measures which lines ran during the tests, not whether the tests checked the result. That gap matters more now, because AI coding agents are very good at producing tests that execute code and very inconsistent at asserting what it should do.

Here is a small function and the kind of test an agent often writes for it:

src/projects.ts
export function canCreateProject(plan: "free" | "pro", existing: number): boolean {
  if (plan === "pro") return true;
  return existing < 5;
}
src/projects.test.ts
it("enforces the free plan limit", () => {
  expect(canCreateProject("free", 2)).toBe(true);
  expect(canCreateProject("pro", 50)).toBe(true);
});

Every line and both branches run, so coverage reports 100%. But the test never checks the one thing its name promises: that a free user with five projects is refused. Change existing < 5 to existing <= 5, or to true, and the test still passes.

How mutation testing works

  1. The tool parses your source and finds places it can make a small change.
  2. Each change produces a mutant: a copy of the program with one deliberate bug.
  3. The tests run against each mutant.
  4. A mutant that makes at least one test fail is killed. A mutant that every test tolerates survived.
Common mutation operators
OperatorExampleWhat a survivor tells you
Comparison< → <=, === → !==No test checks the boundary value
Booleantrue → falseThe flag’s effect is never asserted
Conditionalif (isOwner) → if (true)Nothing tests the case where the condition is false
Block removalAn early return or throw is deletedThe guard can disappear without any test noticing
Arithmetic+ → -The calculated value is not checked

How to read a mutation score

The mutation score is the share of mutants your tests detected. In StrykerJS’s definition, detected mutants are those killed or timed out, and the score is detected divided by detected plus undetected (survived or not covered by any test). Mutants that fail to compile or crash the runner are excluded.

StrykerJS’s default thresholds treat 80% or more as high and below 60% as low, and do not fail the build unless you set a break threshold. The number matters less than the list of survivors. Each surviving mutant points at a specific line and a specific missing assertion, which is far more actionable than a coverage percentage.

Running StrykerJS with Vitest or Jest

StrykerJS is the most widely used mutation testing framework for JavaScript and TypeScript. Its setup wizard detects your test runner and installs the matching plugin:

Terminal
npm init stryker@latest
npx stryker run

A minimal configuration for a Vitest project looks like this:

stryker.config.json
{
  "testRunner": "vitest",
  "mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
  "incremental": true,
  "thresholds": { "high": 80, "low": 60, "break": 60 }
}

incremental reuses results for code that has not changed, which makes repeated runs much faster. Setting break fails the run when the score drops below that value.

Mutation testing only what the AI changed

A full mutation run on a large codebase can take a long time, because every mutant reruns tests. For AI-generated changes, you usually only need to know whether the tests protect the code that just changed. Pass Stryker the changed files:

Terminal
CHANGED=$(git diff --name-only main...HEAD -- 'src/*.ts' ':!*.test.ts' | paste -sd, -)
[ -n "$CHANGED" ] && npx stryker run --mutate "$CHANGED"

MaruCheck builds this in. maru mutate --diff finds mutation candidates only in the changed TypeScript files, runs only the tests linked to the affected requirements, and works in a temporary Git worktree so your checkout is never modified. It focuses on the mutations that matter most in AI-written changes: inverted booleans, changed comparisons, removed guards, and removed ownership or permission conditions.

Terminal
npx --no-install maru mutate --diff --max 8

The unmodified code has to pass first; otherwise the run is reported as inconclusive rather than blaming a mutant. Any survivor is reported as WEAK VERIFICATION DETECTED and blocks the release, with the mutated line and the tests that missed it. See the CLI reference for every option.

When to use mutation testing

  • On critical paths such as authorization, billing, and quotas, on every change.
  • When an AI agent wrote both the implementation and its tests.
  • After an agent edited existing tests. Weakened assertions show up immediately as new survivors. See how agents change tests to make them pass.
  • Before trusting a coverage number in a release decision.

Frequently asked questions

What is mutation testing?
Mutation testing measures whether your tests catch bugs. A tool makes small deliberate changes to production code, such as flipping a comparison or deleting a guard, and reruns the tests. If a test fails, the mutant is killed. If every test still passes, the mutant survived and that behavior is effectively untested.
What is a mutation score?
Mutation score is the percentage of mutants your tests killed out of all mutants that could be evaluated. A high line-coverage number with a low mutation score means the tests execute the code without checking its results. Survived mutants point at the exact lines where an assertion is missing.
What is the best mutation testing tool for TypeScript?
StrykerJS is the most widely used mutation testing framework for JavaScript and TypeScript and supports Jest, Vitest, Mocha, and other runners. For AI-written changes, MaruCheck runs mutation checks only on the changed TypeScript files and the requirement-linked tests, which keeps the run fast enough to use on every pull request.
Why does mutation testing matter for AI-generated tests?
AI agents write tests that run the code without asserting what it should do, and they sometimes weaken tests to make them pass. Coverage cannot see either problem. Mutation testing can, because a test that does not check behavior will keep passing when that behavior is deliberately broken.

Last updated