Ваши тесты проходят. Ваш отчёт о coverage показывает 87%. Но ваш mutation score — 40%, и половина ваших мутантов всё ещё жива.

Эти 40% не означают, что ваш код сломан. Это означает, что сломаны ваши тесты. Coverage измеряет, какие строки выполнились во время прогона тестов. Mutation testing измеряет, заметят ли ваши тесты, если эти строки начнут делать что-то неправильное. Mutation score в 40% означает, что 60% багов, которые могли быть внесены в ваш код, прошли бы прямо через CI.

Что такое выживший мутант на самом деле

Выживший мутант — это маленький искусственный баг, который ваши тесты не смогли поймать.

Инструменты mutation testing работают, беря ваш исходный код и применяя предопределённые трансформации по одной за раз. Они могут заменить > на >=, изменить + на - или подменить булево условие на true. Каждая трансформированная версия вашего кода — это мутант. Инструмент запускает ваш тестовый набор против каждого мутанта. Если какой-либо тест падает, мутант «убит». Если все тесты проходят, мутант «выживает».

Выживший мутант означает одно из двух. Либо ваши тесты на самом деле не проверяют поведение, которое мутант сломал, либо мутант «эквивалентен» (трансформация даёт семантически идентичный код, что является известной сложной проблемой в mutation testing).

Большинство выживших — не эквивалентны. Большинство — это баги-призраки.

Конкретный пример: валидатор паролей

Вот функция, которая проверяет, соответствует ли пароль требованиям политики:

// password.js
function isValidPassword(password) {
  if (password.length < 8) {
    return false;
  }
  if (!/[A-Z]/.test(password)) {
    return false;
  }
  if (!/[0-9]/.test(password)) {
    return false;
  }
  return true;
}

module.exports = { isValidPassword };

А вот тестовый набор, который даёт 100% line coverage:

// password.test.js
const { isValidPassword } = require('./password');

test('accepts a valid password', () => {
  expect(isValidPassword('Hello1')).toBe(true);
});

test('rejects a short password', () => {
  expect(isValidPassword('Hi1')).toBe(false);
});

test('rejects a password without uppercase', () => {
  expect(isValidPassword('hello1')).toBe(false);
});

test('rejects a password without a digit', () => {
  expect(isValidPassword('Hellooo')).toBe(false);
});

Стоп. isValidPassword('Hello1') возвращает true, но 'Hello1' — всего шесть символов. Первая проверка должна была отклонить его. Тест неверен, но он проходит, потому что сам тест проверяет неправильное поведение.

Инструмент mutation testing, например Stryker, поймал бы это. Одна из его мутаций заменила бы < на <= в проверке длины. Этот мутант выжил бы, потому что существующие тесты на самом деле не проверяют границу в 8 символов. Другая мутация могла бы удалить весь первый блок if. Этот мутант тоже выжил бы, потому что в тестах нет восьмисимвольного пароля без заглавной буквы или цифры. Верхняя граница длины никогда не тестируется в комбинации с другими правилами.

Вот тестовый набор, который действительно убивает этих мутантов:

// password.test.js
const { isValidPassword } = require('./password');

test('rejects password shorter than 8 chars', () => {
  expect(isValidPassword('Hello1')).toBe(false);
});

test('accepts password exactly 8 chars with uppercase and digit', () => {
  expect(isValidPassword('Hello1!@')).toBe(true);
});

test('rejects password without uppercase', () => {
  expect(isValidPassword('hello1!@')).toBe(false);
});

test('rejects password without digit', () => {
  expect(isValidPassword('Helloooo')).toBe(false);
});

test('rejects password missing both uppercase and digit', () => {
  expect(isValidPassword('helloooo')).toBe(false);
});

Теперь граница в 8 символов явно протестирована. Мутант <= падает, потому что 'Hello1!@' (8 символов) должен быть принят. Мутант удаления падает, потому что 'helloooo' проскочил бы без проверки длины.

Как mutation testing работает под капотом

Mutation testing вычислительно дорог, потому что он запускает ваш полный тестовый набор один раз на каждого мутанта.

Если в вашей кодовой базе 10 000 строк, а ваш инструмент генерирует 3 000 мутантов, это 3 000 прогонов тестового набора. Ранние академические реализации были практически непригодны для реальных кодовых баз именно по этой причине. Современные инструменты стали умнее.

Stryker, наиболее широко используемый фреймворк mutation testing для JavaScript и TypeScript, использует несколько оптимизаций:

  1. Mutant scoping: Stryker запускает только подмножество тестов, которые теоретически могут достичь мутированной строки, основываясь на данных coverage из начального пробного прогона.

  2. Parallel execution: Мутанты оцениваются параллельно в рабочих процессах.

  3. Incremental mode: Stryker кэширует результаты и переоценивает только мутантов для кода, изменившегося с последнего прогона.

  4. Checkers: Для компилируемых языков Stryker может проверять мутантов на уровне AST без перекомпиляции всего проекта.

Даже с этими оптимизациями полный прогон mutation testing на большой кодовой базе может занимать 10–30 минут. Поэтому большинство команд запускают mutation testing в CI на pull request’ах или ночных сборках, а не при каждом сохранении.

Компромиссы, о которых никто не говорит

Mutation testing не бесплатен, и он не всегда подходит.

Проблема эквивалентных мутантов — самое большое теоретическое ограничение. Некоторые мутации не меняют наблюдаемого поведения. Рассмотрим:

const timeout = 1000 * 60;

Мутация, которая меняет это на 1000 * 61, семантически отличается. Но мутация, которая меняет это на 60 * 1000, эквивалентна. Ни один тест не может её убить, потому что значение идентично. Различение эквивалентных мутантов от настоящих выживших в общем случае неразрешимо. Современные инструменты используют эвристики для пропуска очевидных случаев, но вы всё равно будете видеть некоторые.

Производительность — реальна. На проекте среднего размера на TypeScript Stryker может сгенерировать 2 000 мутантов и потратить 15 минут на их оценку. Это 15 минут CI-времени на каждый прогон, если вы включаете его для pull request’ов. Команды обычно начинают с порога (например, падение сборки, если mutation score опускается ниже 60%) и запускают полный анализ ночью.

Ложная уверенность режет в обе стороны. Mutation score в 100% не означает, что в вашем коде нет багов. Это означает, что ни один баг, соответствующий операторам мутаций инструмента, не проскочил бы. Mutation testing не может придумать баги, которые он не умеет создавать. Он не поймает логические ошибки в ваших требованиях, race conditions, которые он не может симулировать, или интеграционные сбои на границах сервисов.

Как на самом деле начать использовать mutation testing

Если вы пишете на JavaScript или TypeScript, Stryker — это точка старта.

Установите его:

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

Создайте stryker.config.mjs:

// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  packageManager: 'npm',
  reporters: ['html', 'clear-text', 'progress'],
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  mutate: ['src/**/*.js'],
  threshold: {
    break: 60,
  },
};

export default config;

Запустите его:

npx stryker run

Начните с просмотра HTML-отчёта, а не score. Отчёт показывает каждого выжившего мутанта внутри вашего исходного кода. Прочитайте первых десять выживших. Для каждого спросите: вызвал бы реальный баг в этом месте проблему в продакшене? Если да, напишите тест, который его поймает. Если нет, подумайте, не является ли код over-engineered.

Не гонитесь за 100%. На зрелой кодовой базе 70–80% — это сильный score. Ниже 50% у вас, вероятно, есть тесты, которые выполняют код, не проверяя ничего осмысленного. Выше 90% вы, скорее всего, попадаете в область убывающей отдачи и растущего налога на эквивалентных мутантов.

Что делать с вашими 40%

Mutation score в 40% — это подарок. Он точно говорит вам, где ваши тесты декоративны.

Выберите три файла с наибольшим количеством выживших мутантов. Прочитайте каждого выжившего и спросите, какого assertion не хватает. Часто исправление простое: вы вызвали функцию в тесте, но никогда не проверили возвращаемое значение. Или пропустили данные через парсер, но не проверили распарсенный результат. Или протестировали happy path три раза с разными входными данными, но никогда не протестировали ветку ошибки.

Мутанты — это не шум. Это ранжированный список наиболее вероятных мест, где может скрываться нетестированный баг. Начните сверху.


FAQ

В чём разница между code coverage и mutation testing? Code coverage измеряет, какие строки были выполнены. Mutation testing измеряет, упали бы ваши тесты, если бы эти строки содержали баг. 100% coverage при mutation score в 40% означает, что вы прогнали каждую строку, но ваши тесты не заметили бы, если бы большинство из них были неправы.

Может ли mutation testing найти баги в моём существующем коде? Нет. Mutation testing оценивает ваши тесты, а не исходный код. Он говорит вам, где ваши тесты недостаточны. Он не говорит, правильен ли ваш код, только поймали бы ваши тесты определённые классы ошибок.

В каких языках есть хорошие инструменты mutation testing? JavaScript/TypeScript (Stryker), Java (PIT), C# (Stryker.NET), Python (mutmut) и Rust (cargo-mutants) имеют зрелые инструменты. Экосистема варьируется по производительности и поддерживаемым mutation operators.

Должен ли mutation testing заменить code coverage? Нет. Coverage дёшев и быстр. Используйте его для быстрой обратной связи во время разработки. Используйте mutation testing как периодический quality gate для поиска слепых зон, которые coverage не видит.