Покрытие кода тестами обманывает чаще, чем принято думать. Сто процентов покрытия означают лишь то, что каждая строка хоть раз выполнилась во время прогона тестов. О том, поймают ли эти тесты настоящую ошибку, метрика покрытия не говорит ничего. Можно иметь полное покрытие и набор тестов, которые не падают никогда, потому что в них нет ни одной осмысленной проверки. Строки выполнились, галочка зелёная, а тесты пустые. Эта дыра между видимостью и реальностью и есть та боль, которую закрывает мутационное тестирование.
Идея мутационного тестирования звучит почти озорно. Если тесты хорошие, они должны ловить ошибки. Так давайте намеренно внесём ошибку в код и посмотрим, упадёт ли хоть один тест. Инструмент берёт исходный код, делает в нём маленькое изменение, например меняет плюс на минус или больше на больше или равно, и прогоняет тесты против испорченной версии. Если хоть один тест упал, испорченная версия убита, и это хорошо. Если все тесты прошли, значит порчу никто не заметил, испорченная версия выжила, и это сигнал, что тесты дырявые.
Stryker это ведущий инструмент мутационного тестирования для JavaScript, TypeScript, C# и Scala. Дальше идёт разбор того, как он работает, что показывает его метрика, как читать его отчёт и, главное, где мутационное тестирование приносит настоящую пользу, а где превращается в дорогое излишество. Кода ровно столько, сколько нужно увидеть механику, и весь он в отдельных блоках с разбором вокруг.
Как покрытие создаёт ложное чувство безопасности на простом примере
Чтобы увидеть дыру воочию, достаточно представить функцию расчёта цены со скидкой для золотых клиентов. Тест вызывает функцию, она отрабатывает, покрытие показывает сто процентов. А теперь посмотрим, что сделает с этим кодом мутационное тестирование. Оно сгенерирует несколько испорченных версий и проверит каждую.
function priceFor(price, memberLevel) {
if (memberLevel === 'gold') {
return price * 0.8
}
return price
}
Инструмент породит мутантов вроде следующих. Замена множителя скидки с восьми десятых на две десятых. Замена умножения на деление. Удаление условного блока для золотого уровня целиком. И если тест лишь вызывает функцию, но не проверяет конкретный результат для золотого клиента, все три порчи выживут. Тесты пройдут на искажённой логике, потому что им нечем заметить разницу. Оценка мутаций для такого кода окажется около нуля при стопроцентном покрытии. Вот она, та самая пропасть, которую покрытие прячет, а мутационное тестирование вскрывает мгновенно.
Урок прямой. Покрытие отвечает на вопрос, выполнилась ли строка. Мутационное тестирование отвечает на вопрос куда важнее, заметят ли тесты, если эта строка станет неправильной. Слабые проверки и пропущенные граничные случаи всплывают наружу, потому что испорченный код проходит сквозь них незамеченным.
Запуск Stryker и состояния мутантов как язык его отчёта
Поставить Stryker несложно. Есть интерактивная установка, которая сама задаёт вопросы про используемый запускатель тестов и собирает конфигурацию. Либо ставится вручную ядро вместе с адаптером под конкретный запускатель, будь то Jest или Vitest.
# интерактивная установка
npm init stryker
# или вручную под нужный запускатель
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
После запуска инструмент делает то, что и обещал: для каждого сгенерированного мутанта прогоняет весь набор тестов. Отсюда честное предупреждение про время. Первый прогон может оказаться долгим, потому что набор тестов выполняется заново на каждую порчу, и на большой кодовой базе мутантов набираются сотни. Это не недостаток, а природа метода: тщательность стоит времени.
По итогам каждый мутант получает одно из состояний, и эти состояния образуют язык, на котором говорит отчёт. Убитый мутант это порча, которую тесты заметили и упали, и это желанный исход. Выживший мутант это порча, которую никто не поймал, и именно сюда смотрят в первую очередь, потому что он указывает на дыру в проверках. Таймаут это порча, заставившая код зависнуть или превысить лимит времени, что бывает при зацикливании. Отдельно стоят мутанты без покрытия, до которых тесты вообще не добираются, и невалидные мутанты, не скомпилировавшиеся или упавшие с ошибкой ещё до проверки.
npx stryker run
Типичная сводка по завершении выглядит как набор счётчиков и итоговый процент. Например, оценка мутаций семьдесят шесть процентов при двадцати убитых и шести выживших. Эти шесть выживших и есть то место, где спрятана настоящая ценность, потому что каждый из них это конкретная подсказка, какого теста или какой проверки не хватает.
Оценка мутаций как метрика и почему за ней нельзя гнаться вслепую
Оценка мутаций считается просто. Это доля пойманных мутантов от общего числа валидных, выраженная в процентах. Чем выше, тем лучше, и значение выше восьмидесяти процентов обычно считают признаком крепкого набора тестов. Но вокруг этой цифры выросло опасное суеверие, будто целью является сама цифра. Это ошибка того же рода, что и погоня за стопроцентным покрытием.
Опытные практики единодушны в одном. Не надо гнаться за идеальной оценкой мутаций. Фиксированные цели по такой метрике почти всегда приводят к тому, что тесты пишутся ради улучшения числа, а не ради реального улучшения кода. Человек начинает добивать показатель формальными проверками, которые убивают мутантов, но не проверяют ничего осмысленного, и метрика снова отрывается от реальности, ровно как покрытие до неё.
Гораздо ценнее не итоговый процент, а информация в отчёте о выживших мутантах. Один практик честно описал свой опыт: оценка его проекта составила около шестидесяти процентов, и его это не волновало, потому что по-настоящему полезным оказался подробный отчёт, показавший конкретные пробелы. Выяснилось, например, что тесты вызывали разные методы запросов, но не проверяли их результат, и потому удаление тела этих методов проходило незамеченным. Это и есть рабочий режим: не таращиться на число, а копать выживших.
Порог можно зашить в конфигурацию, чтобы пайплайн падал при слишком низкой оценке. Но задавать его стоит как страховку от деградации, а не как самоцель.
// stryker.conf.js
export default {
mutate: ['src/**/*.ts'],
thresholds: {
high: 80,
low: 60,
break: 50,
},
concurrency: 4,
timeoutMS: 60000,
}
Где мутационное тестирование приносит наибольшую пользу
Главный принцип применения звучит так. Не надо гнаться за высокой оценкой по всей кодовой базе. Вместо этого сосредоточьтесь на участках высокого риска и критичной для бизнеса логике, где незамеченная ошибка обошлась бы дороже всего. Именно туда мутационное тестирование вкладывает время с максимальной отдачей.
Первый идеальный кандидат это чистая бизнес-логика с ветвлениями и вычислениями. Расчёты цен, скидок, налогов, начислений, правила доступа, валидация. Здесь каждая граница условия и каждый арифметический оператор важны, а мутационное тестирование как раз и бьёт по ним прицельно, обнажая слабые проверки. Цена ошибки в таком коде прямая и денежная, поэтому крепость тестов вокруг него окупается.
Второй неочевидный, но крайне ценный кандидат это общие модули, от которых зависит множество других тестов. Если сквозные тесты на Playwright или Cypress опираются на общий слой объектов страницы или собственные помощники проверок, мутационное тестирование этих общих модулей особенно полезно. Ошибка в методе общего объекта страницы способна вызвать ложные прохождения сразу в десятках сквозных тестов, и мутационная проверка такого фундамента ловит проблему в корне, до того как она тихо обесценит весь верхний слой.
Третий разумный режим это начинать с малого. Не стоит запускать мутационное тестирование на всей кодовой базе сразу, особенно в первый раз. Лучше выбрать один критичный модуль, прогнать инструмент на нём, разобрать выживших, укрепить тесты и только потом расширять охват. Так и время прогона остаётся посильным, и польза видна сразу.
Где это перебор и когда мутационное тестирование не окупается
У всякого мощного инструмента есть зона, где он избыточен, и честно очертить её важнее, чем расхваливать достоинства. Первый случай перебора это тривиальный код без логики. Простые геттеры, прямые проксирующие функции, конфигурационные объекты, код, который ничего не вычисляет и не ветвится. Мутировать там почти нечего, а время на прогон всё равно тратится, поэтому отдача стремится к нулю.
Второй случай это запуск на всей кодовой базе как обязательный шаг каждой сборки. Поскольку инструмент прогоняет весь набор тестов на каждого мутанта, полный прогон по большому проекту может занимать неприемлемо долго. Ставить такое в блокирующий шаг каждого коммита значит парализовать команду ожиданием. Разумнее гонять мутационное тестирование реже, по расписанию или по запросу на критичных модулях, а не на каждом изменении подряд.
Третий случай это код, нашпигованный внешними эффектами и плохо изолированный. Если функция лезет в сеть, во время, в файловую систему без моков, мутанты будут массово давать таймауты и невалидные состояния, а отчёт утонет в шуме, не имеющем отношения к качеству проверок. Здесь сначала надо навести порядок с изоляцией зависимостей, и лишь потом мутационное тестирование заговорит осмысленно.
Четвёртый случай перебора уже упоминался, но повторить стоит. Погоня за самой цифрой оценки мутаций ради красивого числа в отчёте это вырождение метода. Как только команда начинает писать тесты ради показателя, а не ради уверенности в коде, мутационное тестирование из инструмента превращается в ритуал, такой же пустой, как стопроцентное покрытие пустыми проверками.
Какие именно искажения вносит инструмент и почему их разнообразие важно
Понимание ценности метода углубляется, когда видишь, какими бывают сами искажения. Stryker управляет более чем тремя десятками типов мутаций, и каждый бьёт по своему классу возможных ошибок. Знать их полезно, чтобы осмысленно читать отчёт и понимать, какую именно слабость вскрыл выживший мутант.
Арифметические мутации меняют операторы вычислений: плюс на минус, умножение на деление. Они проверяют, что тесты следят за конкретным результатом расчёта, а не просто за фактом его выполнения. Мутации условий искажают операторы сравнения и логику ветвлений: больше на больше или равно, и на или, истину на ложь. Они обнажают пропущенные граничные случаи, классическую болезнь, когда тест проверяет середину диапазона, но не его края. Мутации, удаляющие целые блоки или возвращающие пустое тело метода, проверяют, что каждая ветвь и каждый побочный эффект действительно под надзором хотя бы одной проверки.
Именно разнообразие искажений делает метод таким въедливым. Один тип мутаций ловит слабые арифметические проверки, другой граничные условия, третий неохваченные побочные эффекты. Вместе они прочёсывают код гораздо плотнее, чем способна любая ручная ревизия тестов. При желании набор активных мутаций настраивается, а ненужные файлы вроде автогенерируемых или миграций исключаются из мутирования, чтобы не тратить время на код, который тестировать бессмысленно.
export default {
mutate: ['src/**/*.ts', '!src/**/*.generated.ts'],
}
Эта настройка прямо продолжает принцип точечности. Чем аккуратнее очерчена область мутирования, тем осмысленнее отчёт и тем меньше времени уходит впустую на код без логики.
Как встроить инструмент в рабочий процесс без паралича команды
Чтобы свести практику к рабочим ориентирам, ниже единственный список этой статьи, расставленный по убыванию важности:
- Гонять мутационное тестирование прицельно на критичной бизнес-логике и общих модулях, а не сплошь по всей кодовой базе, где отдача размывается;
- Читать отчёт ради выживших мутантов и конкретных пробелов в проверках, а не ради итогового процента, который сам по себе мало о чём говорит;
- Не задавать жёстких целей по оценке мутаций, иначе тесты начнут писаться ради числа, а не ради реального улучшения кода;
- Запускать реже блокирующего шага каждого коммита, по расписанию или по запросу, потому что полный прогон тяжёл по времени;
- Начинать с одного модуля, разбирать результат и расширять охват постепенно, держа время прогона посильным;
- Перед мутационным тестированием наводить порядок с изоляцией зависимостей, иначе отчёт утонет в таймаутах и невалидных мутантах.
Эти шесть правил отделяют осмысленное применение от карго-культа. Мутационное тестирование это не очередная метрика для дашборда, а диагностический инструмент, который точечно отвечает на вопрос, ловят ли тесты настоящие ошибки. Его сила именно в точечности, а попытка размазать его на всё подряд эту силу убивает.
Итог укладывается в одну мысль. Покрытие кода говорит, какие строки выполнились, и потому усыпляет бдительность ложным благополучием: можно иметь полное покрытие и тесты, не проверяющие ровным счётом ничего. Мутационное тестирование намеренно портит код и смотрит, заметят ли это тесты, и тем самым отвечает на единственно важный вопрос о качестве проверок. Stryker делает это удобно для основных языков и выдаёт отчёт, где каждый выживший мутант это адрес конкретной дыры. Польза максимальна на критичной логике и общих модулях, а перебор начинается там, где код тривиален, прогон обязателен на каждый коммит или цель подменяется самой цифрой. Команда, которая копает выживших мутантов на важных участках, получает честную картину крепости своих тестов. Команда, которая гонится за процентом по всей базе, получает лишь долгий прогон и новую цифру, которой так же легко обмануться, как и покрытием.