← Back to list

Mutation Testing In React: Your Tests Are Lying to You

You wrote tests. They pass. The CI pipeline is green. You ship.

Emmanuel Chilaka · 2026-03-29 14:37 · 0 claps · 9.3 min read
#mutation-testing #stryker #reactjs #jest #vitest
Open on Medium ↗
Wiki topics: 🌐 · Web Development

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 true becomes return false
  • A number literal changes from 100 to 0
  • 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:

  • mutate points 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: 50 makes 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


메타데이터
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