Ваш отчёт о мутационном тестировании полон выживших, и хотя бы один из них для вас бессмысленен.

Инструмент говорит, что он заменил > на >= в строке 47, или подменил целый условный блок на true, или мутировал строковый литерал, который вы даже не знали, что тестируется. Вы прочитали diff три раза. Всё ещё не понимаете, какое поведение сломал мутант, или какой тест это поймает. И вы пропускаете его. Мутант живёт. Ваш score остаётся низким.

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

Это не так. Вам просто нужна другая отправная точка.

Проблема: вы начинаете с мутации, а не с кода

Большинство разработчиков подходят к выжившим мутантам задом наперёд. Они читают diff мутации, пытаются понять, какой синтетический баг был внесён, а затем пытаются придумать тест, который поймал бы именно этот баг.

Это работает для очевидных случаев. Для чего-то тонкого — нет.

Мутация может находиться внутри вспомогательной функции на три вызова в глубину. Она может повлиять на side effect, о существовании которого вы не знали. Она может быть в сгенерированном коде или в callback фреймворка. Diff показывает, что изменилось, но не почему существующие тесты не обратили на это внимание. Если вы начинаете с декодирования мутации, вы занимаетесь reverse engineering синтетического кода. Это сложно даже для опытных разработчиков.

Лучший подход — полностью игнорировать мутацию и рассматривать выжившего как сигнал о вашем коде, а не о синтетическом баге.

Выживший мутант — это просто строка, которую ваши тесты не проверяют

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

Мутация могла быть чем угодно. Тот факт, что она выжила, означает одно: если бы эта строка выдала неверный результат, ваши тесты всё равно прошли бы. Вам не нужно понимать конкретную мутацию, чтобы это исправить. Вам нужно понять, что эта строка должна делать, и написать тест, который проверяет, сделала ли она это.

Эта переформулировка меняет задачу с reverse-engineering синтетических diff на обычное проектирование тестов.

Метод: работайте от строки назад, а не от мутации вперёд

Вот четырёхшаговый процесс, который работает для любого выжившего мутанта, независимо от того, насколько запутанным выглядит diff.

Шаг 1: найдите точную строку, которую коснулась мутация

HTML-отчёт вашего инструмента мутационного тестирования покажет мутированную строку в контексте исходного кода. Откройте этот файл и найдите оригинальную строку, а не diff.

Например, допустим, Stryker сообщает о выжившем в этой функции:

// pricing.js
function calculateDiscount(price, customer) {
  if (customer.loyaltyYears > 5) {
    return price * 0.85;
  }
  if (customer.isStudent) {
    return price * 0.90;
  }
  return price;
}

module.exports = { calculateDiscount };

Мутация заменила > на >= в первом условии. Это деталь, которая может вас запутать. Пока забудьте о ней. Строка — if (customer.loyaltyYears > 5).

Шаг 2: спросите, какое правило эта строка должна обеспечивать

Не думайте о мутации. Думайте о бизнес-правиле.

Эта строка должна проверять, был ли клиент лоялен более пяти лет. Если да, он получает скидку 15%. Граница важна. Клиент с ровно пятью годами не должен получать эту скидку. Клиент с шестью годами — должен.

Теперь посмотрите на существующие тесты:

// pricing.test.js
const { calculateDiscount } = require('./pricing');

test('returns full price for new customers', () => {
  expect(calculateDiscount(100, { loyaltyYears: 0 })).toBe(100);
});

test('gives loyalty discount to long-term customers', () => {
  expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});

test('gives student discount to students', () => {
  expect(calculateDiscount(100, { isStudent: true })).toBe(90);
});

Тесты покрывают обе ветки первого if. Но они не проверяют границу. loyaltyYears: 5 нигде не появляется. Вот почему мутант >= выжил. Инструмент нашёл пробел, о котором вы не знали.

Шаг 3: напишите тест, который упал бы, если бы эта строка была неверной

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

// pricing.test.js
test('does not give loyalty discount at exactly 5 years', () => {
  expect(calculateDiscount(100, { loyaltyYears: 5 })).toBe(100);
});

test('gives loyalty discount at 6 years', () => {
  expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});

Теперь граница явно выражена. Если кто-то изменит > на >=, первый тест упадёт, потому что клиент с ровно пятью годами некорректно получил бы скидку. Мутант погибает. Вам никогда не приходилось понимать, что означало >= в синтетическом diff.

Шаг 4: запустите мутационное тестирование снова и убедитесь

Запустите ваш инструмент мутации только для этого файла, или запустите полный набор, если вы терпеливы. Выживший должен исчезнуть. Если нет — ваш тест на самом деле не выполняет ту строку, которую вы думаете. Проверьте данные покрытия, чтобы убедиться.

Когда сама строка вызывает недоумение

Иногда мутированная строка находится внутри обёртки библиотеки, хука фреймворка или сгенерированного кода, который вы не писали. В таких случаях выживший говорит вам кое-что другое: в вашей кодовой базе есть код, который ни один человек не понимает достаточно хорошо, чтобы его протестировать.

Это не проблема мутационного тестирования. Это проблема качества кода, которую мутационное тестирование выявило.

Ваши варианты те же, что и без мутационного тестирования: отрефакторите код, пока у него не появится тестируемая поверхность, или примите, что этот код не протестирован, и пометьте его как таковой. Некоторые инструменты позволяют игнорировать конкретные строки или файлы. Используйте эту возможность с осторожностью. Каждый проигнорированный мутант — это баг, который может попасть в продакшн.

Сложный случай: мутации, меняющие side effects

Проверки границ — просто. Side effects — сложнее.

Рассмотрим эту функцию:

// logger.js
function logError(error, context) {
  const timestamp = new Date().toISOString();
  console.error(`[${timestamp}] ${context}: ${error.message}`);
  metrics.increment('error.count');
}

module.exports = { logError };

Инструмент мутационного тестирования может заменить весь вызов console.error на ничего, или заменить строковый шаблон на пустую строку. Эти мутанты выживают, если ваши тесты не проверяют вывод логов.

Большинство команд не тестирует логирование. Обычно это нормально. Но если ваши логи потребляются системой оповещений, или если metrics.increment управляет дашбордом, который будит on-call, то пропускать эти тесты рискованно.

Подход тот же. Не изучайте мутацию. Спросите, какое поведение эта строка должна производить. Если ответ — «структурированная запись лога с timestamp», напишите тест, который проверяет вывод логов:

// logger.test.js
const { logError } = require('./logger');

test('logs error with timestamp and context', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  logError(new Error('db timeout'), 'payment-service');
  expect(spy).toHaveBeenCalledWith(
    expect.stringMatching(/\d{4}-\d{2}-\d{2}T.*payment-service.*db timeout/)
  );
  spy.mockRestore();
});

Мутант, который удаляет вызов console.error, теперь падает, потому что spy обнаруживает отсутствие вызова. Мутант, который портит строковый шаблон, падает, потому что regex не совпадает. Вам не нужно было понимать ни одну из мутаций.

Почему этот подход масштабируется лучше, чем изучение мутаций

Возможных мутаций бесконечное множество. Поведение, которое должен иметь ваш код, конечно.

Если вы пытаетесь писать тесты, которые убивают конкретные мутации, вы играете в whack-a-mole с синтетическими багами. Если вы пишете тесты, которые проверяют реальное поведение вашего кода, мутанты погибают как побочный эффект. Второй подход устойчив. Первый — нет.

Также именно так вы избегаете написания тестов, которые слишком жёстко связаны с инструментом мутаций. Тест, который проверяет, что на строке 47 используется >, — хрупкий. Тест, который проверяет, что пятилетний клиент платит полную цену, — правильный.

Ограничение: эквивалентные мутанты всё ещё существуют

Этот метод не поможет с эквивалентными мутантами, потому что эквивалентные мутанты не представляют недостающих тестов. Они представляют трансформации, которые производят идентичное поведение.

Если мутация меняет a + b на b + a в коммутативной операции, никакой тест не может её убить. Нет недостающего поведения, которое можно проверить. Это ложные срабатывания, и они есть в каждом инструменте мутационного тестирования. Научитесь распознавать их, игнорировать и двигаться дальше. Не позволяйте шумовому порогу в 2% эквивалентных мутантов убедить вас, что остальные 98% — тоже шум.

Начните с трёх худших файлов

Если ваш mutation score низок, и у вас десятки выживших, не пытайтесь понять их всех. Выберите три файла с наибольшим количеством выживших. Для каждого файла выберите три самых подозрительных строки. Примените этот метод к каждой из них.

Через час вы напишете девять тестов, которые сделают вашу кодовую базу более корректной. Перезапустите мутационное тестирование. Ваш score вырастет. Важнее то, что вы поймёте свой собственный код лучше, чем раньше.

Мутанты не просят вас понять их. Они просят вас понять свой код.


FAQ

Нужно ли мне понимать mutation operator, чтобы написать тест? Нет. Mutation operator — отвлекающий фактор. Сосредоточьтесь на том, что оригинальная строка должна делать. Напишите тест для этого поведения. Мутант погибнет как побочный эффект.

Что если мутированная строка находится внутри private-функции, которую я не могу протестировать напрямую? Это сигнал дизайна. Если у функции есть поведение, достойное тестирования, она должна быть тестируемой. Либо откройте её для тестирования, либо протестируйте через публичный API, который её вызывает. Если тест публичного API не может достичь этого поведения, поведение может быть мёртвым кодом.

Должен ли я убивать каждого выжившего мутанта? Нет. Некоторые мутанты затрагивают логирование, метрики или другой observability-код, где стоимость тестирования превышает ценность. Установите порог, который имеет смысл для вашей кодовой базы, и сосредоточьте энергию на мутантах в бизнес-логике.

Что если мой тест убивает мутанта, но всё равно кажется неправильным? Доверьтесь этому чувству. Тест, который случайно убивает мутанта, но не ясно проверяет бизнес-правило, — это технический долг. Перепишите его, чтобы выразить ожидаемое поведение на языке предметной области, а не на языке тестов.