Инженерная практика
Мутационное тестирование — честная метрика качества тестов
Команда требует 100% покрытия строк и добивается его. Руководство успокоено. Затем кто-то запускает Stryker на том же модуле, и мутационный балл возвращается равным 67% — треть внедрённых багов выжила. Худший выживший: Stryker сменил граничную проверку с amount > limit на amount >= limit, прогнал весь набор, и каждый тест всё равно прошёл. Набор выполнял эту строку в каждом прогоне — покрытие 100% — но ни один тест ни разу не подал ровно то граничное значение, на котором > и >= различаются. Число покрытия измеряло, что строка выполнилась, но никогда — что тест заметит её поломку. Это не одно и то же, и зазор составил треть кода.
Покрытие измеряет выполнение, а не обнаружение
Покрытие строк отвечает на слабый вопрос: вызвал ли тест выполнение этой строки? Оно ничего не говорит о том, упадёт ли хоть один тест, будь строка неверной. Этот зазор не академический — это разница между набором, который вас защищает, и набором, который лишь навещает ваш код. Можно вызвать функцию в тесте, выполнить в ней каждую строку и не проверить ничего осмысленного о результате; покрытие засчитает это как покрытое, и реальный баг на этой строке уедет в продакшен зелёным. Высокое покрытие почти необходимо, но радикально недостаточно: это пол «тест коснулся этого», а не потолок «тест стережёт это».
Мутационное тестирование измеряет то, что покрытие не может. Инструмент берёт ваш проходящий набор, вносит маленькое изменение в продакшен-код — мутанта — и перезапускает тесты. Меняет > на >=, + на -, && на ||, return x на return null, удаляет инструкцию. Если теперь тест падает, мутант убит — ваш набор обнаружил внедрённый баг. Если все тесты всё равно проходят, мутант выжил — ваш набор не заметил бы ровно эту поломку. Мутационный балл — это убитые ÷ (всего валидных мутантов), и, в отличие от покрытия, это прямое измерение силы обнаружения. Stryker (JS/TS, C#, Scala) и PIT (JVM) — стандартные инструменты; документация Stryker точно определяет состояния убит/выжил/таймаут/без-покрытия.
| Метрика | На какой вопрос отвечает | Что упускает |
|---|---|---|
| Покрытие строк | Прогнал ли тест эту строку? | Проверяет ли хоть один тест поведение этой строки |
| Покрытие ветвей | Выполнилась ли каждая ветвь? | Тестировалось ли граничное значение, различающее ветви |
| Мутационный балл | Упал бы тест, будь этот код неверным? | Мало что — но он медленный и шумит эквивалентными мутантами |
Выжившие мутанты — это ваши недостающие проверки, названные поимённо
Важен не балл, а список выживших мутантов. Каждый выживший — точное, действенное утверждение: «я изменил вот это, а твоим тестам всё равно». Граничный выживший — > стал >=, и ничего не упало — прямо говорит вам, что не хватает теста на граничном значении, самого частого класса реальных багов. Выживший &&→|| говорит, что логическое условие недотестировано. Выжившее удаление инструкции говорит, что побочный эффект никогда не проверяется. Это и есть настоящий продукт мутационного тестирования: оно превращает «ваши тесты где-то слабы» в «добавь проверку здесь, на этой строке, для этого случая». Это честный ревьюер, который читает ваши проверки, а не отчёт о покрытии.
Окупается оно ровно там, где покрытие лжёт сильнее всего: критическая логика с границами, деньгами, правами, переходами состояний. Его запуск всплывает тесты, которые вы бы написали, додумайся вы до случая — это та же проблема слепых пятен, которую property-тестирование атакует со стороны входов. Эти два подхода дополняют друг друга: property-тесты генерируют входы, которые вы не перечислили; мутационное тестирование находит выходы, которые вы не проверили. Оба существуют потому, что покрытие строк измеряет не то.
Цена: время CPU и эквивалентные мутанты
Мутационное тестирование не бесплатно, и из-за цены команды его дозируют, а не гоняют на всём. Главная цена — время: инструмент перезапускает (подмножество) вашего набора по разу на мутанта, поэтому модуль с сотнями мутантов прогоняет ваши тесты сотни раз. Прогон мутаций по всей кодовой базе может занять часы, что неприемлемо на каждом коммите. Практический шаблон — дозировать: гонять на изменённых файлах в CI (Stryker и PIT оба поддерживают инкрементальные/diff-прогоны) или прогонять полный проход ночью на критических модулях, а не весь репозиторий на каждый push.
Вторая цена — проблема эквивалентного мутанта. Некоторые мутации дают код, поведенчески идентичный оригиналу — например, мутация i < n в i != n в цикле, который всегда инкрементирует на единицу, где оба завершаются одинаково. Ни один тест не может убить эквивалентного мутанта, потому что обнаруживать нечего, но он засчитывается против вашего балла и требует человеческого суждения, чтобы его отбросить. Обнаружение эквивалентных мутантов в общем случае неразрешимо, поэтому вы разбираете их вручную. Это и есть честная сеньорская оговорка: мутационный балл ниже 100% ожидаем, выживших надо читать, а не слепо гнаться за ними, и цель — убить значимых выживших на критическом коде, а не идеальное число на всём.
Почему это работает
Мутационное тестирование честная метрика потому, что оно тестирует тесты той же валютой, что и реальный баг: неверный оператор, перевёрнутое условие, удалённый побочный эффект. Инструмент покрытия удовлетворяется одним лишь выполнением, поэтому его можно обмануть — можно гнаться за 100%, вызывая код без проверок, и число растёт, тогда как набор сильнее не становится. Мутационный балл так не обмануть, потому что единственный способ убить мутанта — иметь проверку, которая реально отличает корректное поведение от внедрённого неверного. Вот почему выживший мутант ценнее процента покрытия: это не статистика о вашем коде, это конкретный баг, который ваш набор только что доказательно пропустил бы.
У вашего биллинг-модуля 100% покрытия строк, но Stryker сообщает мутационный балл 67%, с выжившим граничным мутантом '>' → '>='. Какой ответ правильный?
Как возможно иметь 100% покрытия строк и мутационный балл 67% на одном и том же коде?
Почему сеньору не стоит гнаться за мутационным баллом 100% по всему репозиторию?
Упорядочьте, как мутационное тестирование вскрывает и закрывает пробел в тестах:
- 1 Ваш набор полностью зелёный с 100% покрытия строк
- 2 Инструмент внедряет мутанта: меняет '>' на '>=' в граничной проверке
- 3 Он перезапускает набор; каждый тест всё равно проходит — мутант выживает
- 4 Выживший называет пробел: ни один тест не подаёт граничное значение
- 5 Добавляете граничную проверку; перезапуск — и мутант теперь убит
- 01Руководство доверяет гейту 100% покрытия. Объясните, почему мутационное тестирование, сообщающее 67% на том же коде, не противоречие.
- 02Как сеньору на деле внедрять мутационное тестирование с учётом его цены?
Покрытие строк отвечает на слабый вопрос — прогнал ли тест эту строку — и ничего не говорит о том, упал бы ли хоть один тест, будь строка неверной, поэтому набор может достичь 100% покрытия и всё равно пропустить треть внедрённых багов. Мутационное тестирование измеряет обнаружение напрямую: оно внедряет маленькие изменения (мутант — сменить ’>’ на ’>=’, ’+’ на ’-’, ’&&’ на ’||’, удалить инструкцию), перезапускает набор и сообщает мутанта убитым, если тест падает, или выжившим, если все проходят; мутационный балл — это убитые от всего числа валидных мутантов, а Stryker и PIT — стандартные инструменты. Реальный выход — список выживших, каждый из которых названная недостающая проверка — граничный выживший ’>’ → ’>=’ точно говорит, где добавить тест, для самого частого класса реальных багов. Окупается оно ровно там, где покрытие лжёт сильнее всего: границы, деньги, права, переходы состояний, и оно дополняет property-тестирование, которое атакует то же слепое пятно со стороны входов. Цена настоящая: время прогона, ведь набор перезапускается по разу на мутанта, а полные проходы занимают часы, поэтому дозируйте его до diff’ов в CI или ночью на критических модулях; и эквивалентные мутанты — поведенчески идентичные мутации, которых не убить ни одним тестом, что делает идеальный балл невозможным и требует ручного разбора. Сеньорская цель — убить значимых выживших на критическом коде, а не безупречное число на всём — мутационный балл честная метрика потому, что, в отличие от покрытия, его не удовлетворить выполнением без проверки.