Mutation Testing In React: Your Tests Are Lying to You
You wrote tests. They pass. The CI pipeline is green. You ship.
Mutation Testing In React: Your Tests Are Lying to You
You wrote tests. They pass. The CI pipeline is green. You ship.
Then a bug lands in production that your tests should have caught, a wrong comparison operator, a flipped boolean, or an off-by-one in a discount threshold. You go back and read the test. It was there. It ran. It passed. And it didn’t catch a thing.
That’s not a fluke. That’s what happens when tests measure the wrong thing.
Most test suites measure coverage, the percentage of lines, branches, or statements that ran during the test suite. Coverage is easy to generate and easy to lie with. A test that calls a function and asserts nothing still counts as covered. A test that checks the happy path while the edge cases collapse silently still shows 100%.
Mutation testing measures something different. It doesn’t ask “did your tests run this code?” It asks, “If someone broke this code, would your tests notice?”
What Mutation Testing Actually Does
A mutation testing tool makes small, deliberate changes to your source code, one at a time, and then runs your test suite against each changed version. Each changed version is called a mutant.
The changes are surgical. Things like:
>becomes>=&&becomes||return truebecomesreturn false- A number literal changes from
100to0 - A
+becomes-
If your tests fail after one of those changes, the mutant is killed, and your tests caught the regression. Good.
If your tests still pass after the change, the mutant survived, meaning a real bug of that shape could live in your production code, and your test suite wouldn’t say a word about it.
Your mutation score is the percentage of mutants killed. A 60% mutation score means 40% of the bugs a developer could plausibly introduce would slip past your tests undetected.
Why Coverage Alone Doesn’t Tell You This
Take this function:
export function applyDiscount(price: number, discountPercent: number): number {
return price - (price * discountPercent) / 100;
}
A coverage-passing test:
it('applies a discount', () => {
const result = applyDiscount(100, 20);
expect(result).toBeDefined(); // this is your lie
});
That test runs every line in applyDiscount. Coverage: 100%. But the assertion checks nothing meaningful. If you change / to *, the test still passes. If you change - to +, it still passes. The function is covered but completely unguarded.
Mutation testing would surface this immediately. Both mutants survive, and your score drops.
The Demo App: A Cart Discount Engine
To see mutation testing in action, we’ll build a React cart app with real discount logic, the kind of logic where a wrong operator or a flipped condition has real consequences.
The app lets users:
- Add products to a cart
- Apply discount codes (percentage-based or flat-rate)
- Hit a minimum order threshold before codes activate
- See the final price update in real time
That gives Stryker plenty to work with: boundary checks, arithmetic operators, boolean conditions, and string comparisons.
Project Setup
npx create-react-app stryker-demo --template typescript
cd stryker-demo
npm install --save-dev @stryker-mutator/core @stryker-mutator/react-scripts-runner @stryker-mutator/jest-runner
The Discount Logic
This is the module Stryker will attack. Put it in src/utils/discountEngine.ts:
export type DiscountCode = {
code: string;
type: 'percentage' | 'flat';
value: number;
minimumOrder: number;
};
export const DISCOUNT_CODES: DiscountCode[] = [
{ code: 'SAVE10', type: 'percentage', value: 10, minimumOrder: 50 },
{ code: 'FLAT20', type: 'flat', value: 20, minimumOrder: 75 },
{ code: 'VIP50', type: 'percentage', value: 50, minimumOrder: 100 },
];
export function findDiscount(code: string): DiscountCode | null {
return DISCOUNT_CODES.find((d) => d.code === code.toUpperCase()) ?? null;
}
export function isEligible(subtotal: number, discount: DiscountCode): boolean {
return subtotal >= discount.minimumOrder;
}
export function applyDiscount(subtotal: number, discount: DiscountCode): number {
if (!isEligible(subtotal, discount)) return subtotal;
if (discount.type === 'percentage') {
return subtotal - (subtotal * discount.value) / 100;
}
return Math.max(0, subtotal - discount.value);
}
export function calculateTotal(
subtotal: number,
code: string | null
): { total: number; savings: number; error: string | null } {
if (!code) return { total: subtotal, savings: 0, error: null };
const discount = findDiscount(code);
if (!discount) {
return { total: subtotal, savings: 0, error: 'Invalid discount code' };
}
if (!isEligible(subtotal, discount)) {
return {
total: subtotal,
savings: 0,
error: `Minimum order of $${discount.minimumOrder} required`,
};
}
const total = applyDiscount(subtotal, discount);
return { total, savings: subtotal - total, error: null };
}
The React App
src/App.tsx:
import React, { useState } from 'react';
import { calculateTotal } from './utils/discountEngine';
type Product = { id: number; name: string; price: number };
const PRODUCTS: Product[] = [
{ id: 1, name: 'Mechanical Keyboard', price: 89 },
{ id: 2, name: 'USB-C Hub', price: 45 },
{ id: 3, name: 'Monitor Stand', price: 35 },
{ id: 4, name: 'Desk Mat', price: 28 },
];
export default function App() {
const [cart, setCart] = useState<Product[]>([]);
const [code, setCode] = useState('');
const [appliedCode, setAppliedCode] = useState<string | null>(null);
const subtotal = cart.reduce((sum, p) => sum + p.price, 0);
const { total, savings, error } = calculateTotal(subtotal, appliedCode);
const toggleProduct = (product: Product) => {
setCart((prev) =>
prev.find((p) => p.id === product.id)
? prev.filter((p) => p.id !== product.id)
: [...prev, product]
);
};
const handleApplyCode = () => {
setAppliedCode(code.trim() || null);
};
return (
<div className="app">
<h1>Cart</h1>
<section>
<h2>Products</h2>
{PRODUCTS.map((product) => (
<label key={product.id}>
<input
type="checkbox"
checked={!!cart.find((p) => p.id === product.id)}
onChange={() => toggleProduct(product)}
/>
{product.name} — ${product.price}
</label>
))}
</section>
<section>
<h2>Discount Code</h2>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. SAVE10"
/>
<button onClick={handleApplyCode}>Apply</button>
{error && <p className="error">{error}</p>}
</section>
<section>
<p>Subtotal: ${subtotal.toFixed(2)}</p>
{savings > 0 && <p>You save: ${savings.toFixed(2)}</p>}
<h2>Total: ${total.toFixed(2)}</h2>
</section>
</div>
);
}
The Tests: Before Mutation Testing
src/utils/discountEngine.test.ts:
import { applyDiscount, calculateTotal, findDiscount, isEligible } from './discountEngine';
describe('findDiscount', () => {
it('finds a valid code case-insensitively', () => {
expect(findDiscount('save10')).not.toBeNull();
});
it('returns null for an unknown code', () => {
expect(findDiscount('FAKE')).toBeNull();
});
});
describe('isEligible', () => {
const discount = { code: 'SAVE10', type: 'percentage' as const, value: 10, minimumOrder: 50 };
it('allows order exactly at minimum', () => {
expect(isEligible(50, discount)).toBe(true);
});
it('blocks order below minimum', () => {
expect(isEligible(49, discount)).toBe(false);
});
});
describe('applyDiscount', () => {
it('applies percentage discount', () => {
const d = { code: 'SAVE10', type: 'percentage' as const, value: 10, minimumOrder: 50 };
expect(applyDiscount(100, d)).toBe(90);
});
it('applies flat discount', () => {
const d = { code: 'FLAT20', type: 'flat' as const, value: 20, minimumOrder: 75 };
expect(applyDiscount(100, d)).toBe(80);
});
it('does not go below zero on flat discount', () => {
const d = { code: 'FLAT20', type: 'flat' as const, value: 20, minimumOrder: 0 };
expect(applyDiscount(10, d)).toBe(0);
});
});
describe('calculateTotal', () => {
it('returns subtotal with no code', () => {
expect(calculateTotal(100, null).total).toBe(100);
});
it('returns error for invalid code', () => {
expect(calculateTotal(100, 'WRONG').error).toBe('Invalid discount code');
});
it('returns error when below minimum', () => {
expect(calculateTotal(30, 'SAVE10').error).toContain('Minimum order');
});
it('returns correct total and savings for valid code', () => {
const result = calculateTotal(100, 'SAVE10');
expect(result.total).toBe(90);
expect(result.savings).toBe(10);
});
});
These tests look solid. They cover the main paths and check actual values. Run them, and they all pass.
Now let’s run Stryker and see what they’re hiding.
Setting Up Stryker
Create stryker.config.mjs at the root of the project:
// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
testRunner: 'jest',
coverageAnalysis: 'perTest',
mutate: ['src/utils/discountEngine.ts'],
jest: {
projectType: 'create-react-app',
},
thresholds: {
high: 80,
low: 60,
break: 50,
},
reporters: ['html', 'clear-text', 'progress'],
};
export default config;
A few things to notice here:
mutatepoints only at the logic file, not the React components. UI rendering code produces a lot of noise in mutation reports. Target your business logic.coverageAnalysis: 'perTest'tells Stryker which tests relate to which code, so it only runs relevant tests per mutant instead of the full suite. This is the setting that makes Stryker fast enough to be usable.thresholds.break: 50makes the CI process exit with a non-zero code if the mutation score drops below 50%. This is how you enforce mutation coverage in a pipeline.
Run it:
npx stryker run
The first run takes time. Stryker generates dozens of mutants and runs your test suite against each one. Go get a coffee.
Reading the Report
When it finishes, Stryker opens an HTML report at reports/mutation/mutation.html. It looks like a code browser, but each highlighted section tells you what changed and whether your tests caught it.
You’ll likely see something like this for isEligible:
Survived: BoundaryMutation on line 17
Original: subtotal >= discount.minimumOrder
Mutant: subtotal > discount.minimumOrder
That’s a problem. The difference between >= and > means a cart with exactly $50 subtotal would be refused the SAVE10 code even though the minimumOrder is $50. Your test at isEligible(50, discount) returned true, and you checked it. But Stryker changed >= to >, and the test... still passed?
Go back and check the test:
it('allows order exactly at minimum', () => {
expect(isEligible(50, discount)).toBe(true);
});
Wait, that should catch it. isEligible(50, discount) with > would return false, not true. This one should be killed.
If it survived, check your discount definition in the test. The minimumOrder might be set to 49 instead of 50, a copy-paste error in the test data. Stryker just found a bug in your test.
That’s the thing about mutation testing: it doesn’t just find gaps in assertions. It finds wrong test data, misleading variable names, and tests that pass for the wrong reason.
Stryker’s terminal summary will look something like:
Mutation testing is done. Here is your final score:
Mutants: 42
Killed: 31 (73.8%)
Survived: 9 (21.4%)
No coverage: 2 (4.8%)
Timeout: 0 (0.0%)
Mutation score: 73.81%
73.8% isn’t bad for a first run. But those 9 surviving mutants represent real gaps. Let’s fix them.
Killing the Survivors
Surviving Mutant 1: The Boundary Flip
Stryker changes >= to > in isEligible. If your test has:
expect(isEligible(50, discount)).toBe(true); // minimumOrder: 50
…it kills this mutant. But if you forgot the exact-boundary case, add it explicitly:
it('allows order exactly at minimum threshold', () => {
expect(isEligible(50, discount)).toBe(true);
});
it('blocks order one dollar below minimum', () => {
expect(isEligible(49, discount)).toBe(false);
});
Both tests together force >=either alone doesn't.
Surviving Mutant 2: The Arithmetic Flip
Stryker changes / to * in the percentage calculation:
// Original
return subtotal - (subtotal * discount.value) / 100;
// Mutant
return subtotal - (subtotal * discount.value) * 100;
Your test expect(applyDiscount(100, d)).toBe(90) kills this one because 100 - 100 * 10 * 100 is wildly wrong. Good.
But Stryker might also try changing - to +:
// Mutant
return subtotal + (subtotal * discount.value) / 100;
applyDiscount(100, d) returns 110 instead of 90. Your assertion toBe(90) catches it. Killed.
If you see either of these surviving, your assertion is probably wrong, maybe toBeDefined() instead of a specific number. Fix the assertion.
Surviving Mutant 3: The Null Guard
Stryker removes the null check in calculateTotal:
// Original
if (!code) return { total: subtotal, savings: 0, error: null };
// Mutant: condition removed, always proceeds
Your test expect(calculateTotal(100, null).total).toBe(100) kills this only if passing null causes a crash or wrong value without the guard. Add a test for an empty string too:
it('returns subtotal with empty string code', () => {
expect(calculateTotal(100, '').total).toBe(100);
});
Surviving Mutant 4: String Comparison
Stryker changes === to !== in findDiscount:
// Mutant
return DISCOUNT_CODES.find((d) => d.code !== code.toUpperCase()) ?? null;
Your test expect(findDiscount('save10')).not.toBeNull() passes even with !== because the mutant now returns the first code that doesn't match 'SAVE10', which is 'FLAT20'. not.toBeNull() passes.
Fix it with a specific assertion:
it('finds the correct discount for a valid code', () => {
const result = findDiscount('save10');
expect(result?.code).toBe('SAVE10');
});
Now the mutant is killed because result?.code would be 'FLAT20', not 'SAVE10'.
After the Fixes
Re-run Stryker. With these improvements, a realistic final score looks like:
Mutants: 42
Killed: 40 (95.2%)
Survived: 2 (4.8%)
No coverage: 0 (0.0%)
Mutation score: 95.24%
The 2 survivors are often timeout or equivalent mutant cases, mutations that produce the same external behavior as the original code through a different code path. Those aren’t real bugs.
Adding Stryker to an Existing App
If you’re not starting fresh, here’s how to drop mutation testing into an app that’s already running.
Step 1: Install
For Jest (the most common React setup):
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
For Vitest:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
Step 2: Generate a Config
npx stryker init
Stryker’s init wizard detects your test runner and writes a starter config. You’ll want to edit it.
Step 3: Scope It Down First
Don’t run Stryker against your entire codebase on day one. You’ll get hundreds of mutants, a slow run, and a demoralizing score.
Pick one module, ideally a pure logic file with no side effects:
// stryker.config.mjs
mutate: ['src/utils/pricing.ts'],
Get that score above 80%, then widen the scope.
Step 4: Exclude the Noise
Some files produce mutants that are useless to test, type definitions, constants, generated code, and i18n strings:
mutate: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/i18n/**',
'!src/generated/**',
],
Step 5: Use --incremental for Speed
After the first run, Stryker can cache results and only re-run mutants for code that changed:
npx stryker run --incremental
This makes local runs fast enough to be part of your regular workflow, not just a CI gate.
Step 6: Add It to CI
In a GitHub Actions workflow:
- name: Run mutation tests
run: npx stryker run
env:
CI: true
Set thresholds.break to the score below which the build should fail. Start low (50%) and raise it as you improve coverage over time. Don't set it to 80% on a legacy codebase day one; you'll just disable it when it fails.
Step 7: Treat It as a Code Review Tool
The HTML report is your best friend. Sort by “Survived” and look for patterns. Surviving mutants in the same file often point to a missing test category, not just a missing test. Three surviving arithmetic mutants in one function usually means the function is tested for existence rather than correctness.
The Mental Shift
Coverage tells you what code ran. Mutation testing tells you what code matters.
Once you’ve seen Stryker find a surviving mutant in code you were confident about, your relationship with test assertions changes. toBeDefined() is starting to feel like a liability. You start writing numbers in assertions instead of shapes. You start checking boundary conditions by default.
That’s the real output of mutation testing, not just a score, but a different instinct for what a test is supposed to do.
Your tests can pass and still lie to you. Stryker makes them prove it.
Resources
- Stryker Mutator documentation
- Stryker configuration reference
- Mutation testing explained — Stryker blog
- Full project code (all files, runnable): see Github Repo link
메타데이터
- post_id
- cbc446935671
- slug
- your-tests-are-lying-to-you-cbc446935671
- url
- https://medium.com/@echilaka/your-tests-are-lying-to-you-cbc446935671
- canonical_url
- https://medium.com/@echilaka/your-tests-are-lying-to-you-cbc446935671
- author_url
- https://medium.com/@echilaka
- status
- ok
- fetched_at
- 2026-06-20 20:29:01