Прямой ответ
Мутационное тестирование отвечает на вопрос, который не задаёт покрытие кода: обнаружат ли тесты ошибку, если она появится в коде. Метод простой: в исходный код вносятся небольшие преднамеренные изменения — мутанты, — после чего запускается тестовый набор. Если тесты падают, мутант «убит»; если проходят — мутант «выжил», и это указывает на слабое место в тестах.
Примеры мутаций:
| Было | Стало |
|---|---|
a + b |
a - b |
x > 0 |
x >= 0 |
return True |
return False |
for i in range(n) |
for i in range(n + 1) |
if cond: |
if not cond: |
x * 2 |
x * 3 |
Основная метрика — mutation score (kill rate): доля убитых мутантов от общего числа. Значение 85 % означает, что каждая седьмая внесённая ошибка прошла бы через тесты незамеченной.
В Python основной инструмент — mutmut: он анализирует исходники, генерирует мутанты и запускает для каждого соответствующий набор тестов.
pip install mutmut
mutmut run --paths-to-mutate=src/billing/ --tests-dir=tests/
mutmut results # список выживших и подозрительных
mutmut show 42 # конкретная мутация
Важно понимать разницу с покрытием: тест может выполнить строку, но не проверить результат. Покрытие покажет 100 %, мутационное тестирование — что изменение в этой строке никто не заметит. Подробно о том, что именно показывает метрика покрытия, — в статье про покрытие кода.
Практика: как применять и не утонуть
1. Запускайте на отдельном модуле, а не на всём проекте. Мутационное тестирование дорогое: для каждого мутанта выполняется прогон тестов. Модуль на 300 строк даёт сотни мутантов, а полный прогон может занимать часы.
2. Сначала ускорьте тесты. Практический способ — запускать только те тесты, которые покрывают мутируемую строку. Mutmut использует результаты покрытия для отсечения лишних тестов, поэтому прогон с собранным coverage заметно быстрее.
3. Различайте типы результатов.
- Killed — мутант обнаружен, тесты работают.
- Survived — тесты не заметили изменение; это сигнал к действию.
- Timeout — мутант привёл к зависанию (например, бесконечный цикл); обычно считается обнаруженным.
- Suspicious — прогон завершился нештатно; требует ручной проверки.
4. Эквивалентные мутанты существуют. Часть мутаций не меняет поведение программы: x * 1 → x / 1, замена i += 1 на i = i + 1 внутри выражения без побочных эффектов. Такие мутанты невозможно убить, и они занижают метрику. Практика — не стремиться к 100 %, а разбирать выживших по существу.
5. Разбирайте выживших группами. Обычно они концентрируются: ветки обработки ошибок, редкие условия, код с неявными соглашениями. Одна добавленная проверка часто убивает десяток мутантов.
# мутант: discount > 0.5 -> discount >= 0.5
# тесты проходили, потому что граничное значение 0.5 не проверялось
@pytest.mark.parametrize("discount, expected", [
(0.0, 100.0), (0.49, 100.0), (0.5, 100.0), (0.51, 95.0), (1.0, 50.0),
])
def test_discount_threshold(discount, expected):
assert round(apply_discount(100.0, discount), 2) == expected
Рекомендации:
- Не превращайте mutation score в KPI. Как только за цифру начинают отвечать, код заполняется тестами, которые убивают мутантов, но не проверяют поведение.
- Приоритизируйте бизнес-логику. Расчёт денег, прав доступа, состояний — то, где выживший мутант стоит дороже всего. Утилиты и маппинги проверяют дешевле другими способами.
- Добавляйте граничные значения. Большинство выживших мутантов связано с условиями: замена строгого сравнения на нестрогое ловится только тестом на границе.
- Комбинируйте с property-based. Свойства, сформулированные в Hypothesis-тестах, убивают мутантов массово, потому что проверяют инвариант, а не одно значение.
- Запускайте периодически, а не на каждый коммит. Разумный режим — ночной прогон по критичным модулям или проверка перед крупным рефакторингом.
Практический сценарий: покрытие 94 %, но половина мутантов выжила
Контекст: модуль расчёта скидок имел покрытие 94 % и 180 тестов. Перед рефакторингом решили проверить, защищают ли тесты от регрессий.
- Запуск. Mutmut сгенерировал 412 мутантов; убито 201, выжило 187, остальные — таймауты и подозрительные.
- Анализ выживших. 63 мутанта относились к одному условию выбора тарифной сетки, 41 — к округлению, 30 — к обработке пустого списка позиций.
- Причина. Тесты проверяли «типичные» значения: скидка 10 %, количество 3. Граничные значения (0, 1, пороговое количество, пустой список) отсутствовали, а округление проверялось только на круглых числах.
- Действия. Добавили параметризованные тесты на границы и на пустые коллекции; для округления — значения с половиной копейки в обе стороны. Часть проверок переписали как свойства: «итог не превышает сумму позиций», «скидка монотонна по количеству».
- Результат. Повторный прогон: убито 369 из 412, выжило 28, из них 19 признаны эквивалентными мутантами. Фактический уровень защиты вырос примерно с 49 до 89 %.
Вывод: высокое покрытие создавало ложное ощущение защищённости. Реальная проверка качества тестов потребовала одного прогона мутационного анализа и примерно двух дней работы над граничными случаями.
FAQ
Почему бы не полагаться на покрытие кода? Покрытие измеряет, какие строки выполнялись, но не проверяет, были ли результаты проверены. Тест без утверждений даёт полное покрытие и нулевую защиту. Мутационное тестирование измеряет именно способность тестов замечать изменения.
Сколько времени занимает прогон? Зависит от скорости тестов и числа мутантов. Ориентир: модуль на несколько сотен строк с быстрым набором тестов — десятки минут; крупная кодовая база без отсева по покрытию — часы. Параллелизация и отсечение по покрытию сокращают время в разы.
Что делать с эквивалентными мутантами? Принять их как данность и не гнаться за 100 %. Полезная практика — помечать разобранные случаи, чтобы не анализировать их повторно при следующем прогоне.
Есть ли смысл запускать мутации в CI на каждый коммит? Обычно нет: это слишком дорого. Разумнее ночной прогон по критичным модулям с отчётом о выживших и ручной разбор раз в спринт, либо прогон перед крупными изменениями.
Источники: mutmut (документация проекта), pytest docs, Wikipedia (mutation testing) — ссылки в шапке.