Mutation Testing in TypeScript: Breaking Code to Make It Better

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Are you sure your tests actually work?

\n\n

Imagine: you have 95% code coverage, all tests are green, CI/CD passes without errors. The team is happy, the manager is calm. But one day a bug surfaces in production. You open the code, look at the tests, and realize: they simply check that the function returns a number, but don't look at its value. Or worse — the test is written in such a way that it passes no matter what.

\n\n

Sound familiar? Welcome to the world where coverage percentage is just a number, not a quality guarantee. This is where mutation testing enters the stage — a methodology that truly makes your tests suffer and prove their worth.

\n\n

In this article, we'll break down how to implement mutation testing in a TypeScript project using Stryker, and why it could become your new favorite tool (or a new source of headaches).

\n\n

What is mutation testing in simple terms?

\n\n

Mutation testing is when you take your code, intentionally introduce small errors (mutations) into it, and see if your tests fail. If the tests don't fail — it means you're testing incorrectly. If they fail — well done, the mutant is killed.

\n\n

Sounds like sadism? Possibly. But it works. Imagine you're a QA engineer who secretly snuck into the code and replaced === with ==, > with <, or removed a function call. A good test must catch this. A bad one will let it through.

\n\n

In TypeScript, with its strict typing, mutation testing is especially useful: it catches cases where the types are correct, but the logic is not.

\n\n

Why is regular code coverage an illusion?

\n\n

Let's say you have a function:

\n\n
function calculateDiscount(price: number, isPremium: boolean): number {\n  if (isPremium) {\n    return price * 0.8;\n  }\n  return price;\n}
\n\n

And a test:

\n\n
test('returns number', () => {\n  const result = calculateDiscount(100, true);\n  expect(typeof result).toBe('number');\n});
\n\n

Coverage? 100%. Test? Passes. But what if we replace price * 0.8 with price * 0.5? The test will still pass — it only checks the type, not the value. There you have it — a false sense of security.

\n\n

Mutation testing will highlight such a mutation and say: "Hey, your test didn't notice I changed the discount!". And you'll have to write a proper check.

\n\n

How mutation testing works in TypeScript: step by step

\n\n

The entire process can be broken down into three stages:

\n\n
    \n
  1. Mutant generation — the tool copies your code and makes one change at a time. For example, it changes && to || or removes a method call.
  2. \n
  3. Running tests — your tests are run for each mutant.
  4. \n
  5. Result analysis — if the tests fail, the mutant is "killed". If not, it "survived", and that's a risk zone.
  6. \n
\n\n

The main metric is the mutation score (percentage of killed mutants). 100% is ideal, but in practice 80-90% is already excellent.

\n\n

Tool #1 for TypeScript: Stryker

\n\n

In the world of TypeScript and JavaScript, the king of mutation testing is Stryker (StrykerJS). It's fast, works with Jest, Vitest, Mocha, and even browser tests. It can be configured in 5 minutes.

\n\n

Let's install it:

\n\n
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
\n\n

Create a config file stryker.conf.js:

\n\n
// stryker.conf.js\nexport default {\n  mutate: ['src/**/*.ts', '!src/**/*.spec.ts'], // what to mutate\n  testRunner: 'jest',\n  reporters: ['html', 'clear-text', 'progress'],\n  jest: {\n    projectType: 'custom',\n    configFile: 'jest.config.ts',\n  },\n  concurrency: 4, // how many mutants                

Blogs

Book Recommendations