Баг был скучный до неприличия. Сервис считал процент от суммы заказа. Для Казахстана результат округлялся до двух знаков после запятой: 5,73637748 превращалось в 5,74. Для Кувейта нужно три знака – 5,736. Разница в тысячные доли.
Умножьте эту разницу на 300 000 заказов в день.
Один из участников сообщества пошел разбираться и открыл класс, который отвечал за округление. Класс был покрыт тестами почти полностью и в команде считался пуленепробиваемым. Он прочитал все тесты глазами. Половина из них проверяла, что поле пришло. Не логику. Не граничные значения. Не количество знаков. Просто наличие данных.
Из интереса он открыл соседний класс. Там то же самое: почти сплошной Assert.NotNull.
Общее покрытие проекта при этом составляло 92%.
Что на самом деле измеряет покрытие
Покрытие тестами – метрика исполнения. Она отвечает ровно на один вопрос: была ли эта строка выполнена хотя бы раз, пока крутились тесты. Все.
На вопрос, который вас интересует на самом деле, она не отвечает. А интересует вас другое: заметит ли тест, если эта строка сломается.
Разница между двумя вопросами кажется схоластической ровно до первого инцидента. Тест вызывает метод расчета и проверяет, что результат не null. Он честно прогоняет через себя все ветвления внутри. Покрытие растет. Отчет зеленеет. Поменяйте внутри плюс на минус – тест как проходил, так и проходит.
Получается метрика, которая измеряет активность инструмента, а не его чувствительность. Примерно как оценивать пожарную сигнализацию по тому, что через нее прошел воздух.
Отдельная ирония в том, что покрытие – одна из немногих метрик качества, которую легко автоматизировать и вывести на дашборд. Поэтому именно она обычно и попадает в квартальные цели. Дальше срабатывает закон Гудхарта: показатель, ставший целью, перестает быть хорошим показателем. Команда дописывает тесты до нужного процента самым дешевым доступным способом. Самый дешевый способ – проверить, что что-то вернулось.
Тест для тестов
Автор кейса сформулировал задачу непривычно: а можно ли протестировать сами тесты?
Можно. Практика называется мутационное тестирование, и ей примерно столько же лет, сколько юнит-тестам: первые работы вышли в конце 1970-х. Массово ее не используют по банальной причине – она дорого стоит по времени. Идея при этом умещается в одно предложение.
Инструмент берет ваш рабочий код и намеренно его ломает. Меняет плюс на минус. Меняет > на >=. Инвертирует условие. Выкидывает вызов метода. Заменяет возвращаемое значение на пустое. Каждая такая порча называется мутацией: отдельная сборка проекта с одним внесенным дефектом.
Дальше на каждой испорченной сборке прогоняются ваши тесты.
- Если хоть один тест упал, мутация убита. Тесты заметили дефект, все хорошо.
- Если все тесты прошли, мутация выжила. Вы внесли в код настоящую ошибку, а набор тестов этого не заметил.
Выжившая мутация – не гипотеза и не запах. Это конкретная строка кода, которую можно сломать, и ваша сборка останется зеленой.
Когда автор кейса запустил инструмент локально, экран был красный. Мутации выживали даже там, где удалялись целые условия и ветки switch. При 92% покрытия.
Если тест успешно прошел, то мутация выжила, а это значит, что тест неточный.
Инструментов хватает почти под любой стек: Stryker для JavaScript, C# и Scala, PIT для Java, mutmut и cosmic-ray для Python, go-mutesting для Go. Все они работают по одной схеме и дают одну итоговую цифру – mutation score, долю убитых мутаций. В отличие от покрытия, эта цифра говорит о качестве тестов, а не об их наличии.
Почему это не живет в CI
Первая реакция инженера, который услышал про мутационное тестирование, всегда одинаковая. Отлично, добавим в пайплайн, пусть гоняется на каждом пулреквесте.
В обсуждении это возражение прозвучало сразу: чем оно принципиально отличается от любого статического анализа в CI. Ответ автора кейса был коротким. В CI это дорого и долго.
Арифметика простая. Мутационный прогон – это не один прогон тестов, а по одному прогону на каждую мутацию. Инструменты умеют отсекать лишнее: гонять только те тесты, которые покрывают измененную строку, распараллеливать, работать по инкременту от последнего коммита. Но даже с оптимизациями на среднем сервисе речь идет о часах, а не о минутах.
Поэтому в кейсе выбрали другую частоту. Схема получилась такая:
- В Kubernetes стоит cron job, которая раз в месяц поднимает отдельный контейнер: 4 vCPU, 8 ГБ памяти.
- В контейнере запускается Stryker. Прогон занимает 2–3 часа.
- Отчет автоматически падает в канал в Slack.
- Команда смотрит на выжившие мутации и оценивает их.
- То, что признано значимым, уходит в бэклог как обычные задачи.
Ключевое здесь – пункт четыре. Отчет не блокирует релиз и не заводит задачи сам. Он приносит список мест, где тесты слепые, а решение принимают люди.
Такая асимметрия вообще характерна для дорогих проверок. Быстрые и дешевые сигналы (линтер, компилятор, юнит-тесты) имеет смысл ставить блокирующим гейтом. Медленные и глубокие (мутационное тестирование, фаззинг, полный прогон нагрузочных) работают как регулярная ревизия. Первые не пускают в мастер, вторые формируют бэклог. Попытка превратить вторых в первых обычно заканчивается тем, что пайплайн отключают целиком.
Чего мутационное тестирование не сделает
Практика полезная, но у нее есть честные ограничения. Лучше узнать о них до внедрения, а не после.
Эквивалентные мутации. Часть внесенных дефектов не меняет поведение программы вообще. Классика: замена > на >= в цикле, который в реальности никогда не доходит до границы. Такая мутация выживет всегда, и никакой тест ее не убьет, потому что убивать нечего. В отчете это шум, который приходится отсеивать глазами. Доля эквивалентных мутаций – главная причина, по которой mutation score никогда не стоит гнать к 100%.
Стоимость времени. Часы прогона – нормально для месячной ревизии и невыносимо для обратной связи разработчику. Мутационное тестирование не заменяет TDD и вообще не про скорость цикла.
Только то, что покрыто. Инструмент ломает строки и смотрит на тесты. Если у куска кода тестов нет совсем, мутация в нем выживет, но это вы и так знали из отчета о покрытии. Ценность появляется именно там, где покрытие уже высокое и создает ложное ощущение безопасности.
Только юнит-уровень. Ошибка с Кувейтом – про конфигурацию страны и бизнес-правило, и поймана она была бы на уровне класса. Интеграционные проблемы, гонки, неверные предположения о внешнем API мутационный прогон не увидит.
Насколько можно судить по одному внедрению, это не серебряная пуля, а конкретный инструмент для конкретного вопроса: врут ли мои юнит-тесты. На этот вопрос он отвечает лучше остальных.
Отдельная причина заняться этим прямо сейчас
Есть контекст, из-за которого тема из академической стала практической.
Тесты все чаще пишет не человек. Разработчик просит агента покрыть класс тестами, агент покрывает, покрытие растет, пулреквест выглядит убедительно. Проблема в том, что модель оптимизирует ровно то, что вы попросили: наличие тестов и зеленый прогон. Самый надежный способ получить зеленый прогон – проверять слабые утверждения.
В том же обсуждении участник разбирал ситуацию буквально этими словами: половина тестов сгенерирована по принципу «это поле точно пришло», а логику не тестировал никто. Фразу сегодня можно услышать про добрую половину новых репозиториев.
Дальше видна вилка. Если вы отказываетесь от построчного чтения сгенерированного кода (а к этому идет все больше команд), то приемкой становятся тесты. Но если тесты пишет тот же агент, что и код, то приемка проверяет сама себя. Нужен внешний арбитр, который не зависит от намерений автора.
Мутационное тестирование на эту роль подходит буквально. Оно не читает тесты и не оценивает их стиль, оно ломает код и смотрит на реакцию. Инструменту все равно, что сгенерировало тест: человек, агент или генератор шаблонов. Проверяется поведение, а не происхождение.
Что можно сделать за один вечер
- Возьмите самый ответственный класс в системе – расчеты, тарификация, права доступа, статусы заказа. Не весь проект, один класс.
- Посмотрите его тесты глазами и посчитайте долю утверждений вида «не пусто», «не null», «статус 200».
- Поставьте мутационный инструмент под свой стек и запустите его локально по этому одному классу.
- Разберите первые десять выживших мутаций. Отделите эквивалентные от настоящих дыр.
- Если настоящих дыр больше трех – заведите регулярный прогон по расписанию, а не в CI. Раз в месяц достаточно, чтобы видеть тренд.
- Отчет отправляйте в канал команды, а не лично себе. Смысл в том, чтобы выжившая мутация была видна автору теста.
- В определение готовности добавьте не процент покрытия, а формулировку: критичная логика не должна переживать удаление своего условия.
Отдельно стоит договориться, что mutation score не становится KPI. Иначе через квартал вы получите те же игры с цифрой, только на уровень глубже.
Что с этим делать тимлиду
Самая неприятная часть истории – не баг с округлением. Неприятная часть в том, что команда год жила с ощущением, что этот кусок системы защищен. Ощущение было основано на цифре, которая ничего про защиту не говорила.
Метрики качества имеют свойство превращаться в декорацию именно тогда, когда их удобно измерять. Покрытие удобно измерять. Количество тестов удобно измерять. Процент зеленых прогонов удобно измерять. Все три растут, когда команда пишет слабые тесты, и не падают, когда система становится хрупче.
Мутационное тестирование неудобно: медленное, шумное, требует разбора руками. Зато оно отвечает на вопрос, который вы на самом деле задаете, когда смотрите на дашборд. Если завтра что-то сломается, узнаю ли я об этом до клиента.
Какой процент ваших тестов переживет удаление ближайшей ифки – и хотите ли вы узнать ответ до того, как его узнает Кувейт?
Статья выросла из обсуждения в сообществе «Тимлид не кодит». Контекст – в инсайтах за 10–16 августа. Присоединяйтесь к обсуждению в Telegram.