Мутационное тестирование в Python: тесты, которые ловят баги
Покрытие полное, а баги всё равно проходят в прод. Мутационное тестирование ломает ваш код специально и проверяет, заметят ли это тесты. Разбираем на mutmut и cosmic-ray.

Покрытие по coverage показывает 95%, CI зелёный, а на проде выясняется, что тест дёргал функцию, но не проверял результат. Строка выполнилась — значит, покрыта. Что она вернула, тесту всё равно. Мутационное тестирование бьёт именно в это слепое пятно: инструмент вносит в ваш код мелкие поломки (мутации) и запускает тесты. Если тесты продолжают проходить на сломанном коде — они ничего не проверяют, только греют CPU.
Разберём, как это работает в Python, чем mutmut отличается от cosmic-ray, сколько это стоит по времени на реальном проекте и когда мутационное тестирование помогает, а когда только раздражает команду. Инвалидация тестов и рефакторинг под мутантов — отдельная тема, здесь смотрим только на выбор и запуск инструмента.
Что такое мутант и почему покрытие врёт
Мутационный фреймворк берёт ваш исходник и генерирует его изменённые копии. Типичные мутации: заменить + на -, > на >=, True на False, выкинуть тело функции, заменить return x на return None. Каждая такая копия — отдельный мутант.
Дальше на каждом мутанте прогоняются тесты. Возможны два исхода:
- Мутант убит — хотя бы один тест упал. Хорошо: тесты заметили поломку.
- Мутант выжил — все тесты прошли, несмотря на сломанный код. Плохо: этот кусок логики фактически не проверяется.
Показатель качества — mutation score1: доля убитых мутантов от всех жизнеспособных. Он честнее покрытия, потому что отвечает не на вопрос «выполнилась ли строка», а на вопрос «заметит ли тест, если строка сломается».
Покрытие говорит, какой код вы запустили. Mutation score говорит, какой код вы проверили. Это разные числа, и второе обычно сильно меньше первого.
Два инструмента: mutmut и cosmic-ray
В экосистеме Python живут два основных проекта. Оба работают с обычными pytest-тестами, но по-разному организованы.
mutmut
Ставится и запускается быстрее всего, минимум конфигурации. Хранит результаты в локальном кеше, умеет показывать конкретные выжившие мутации построчно. Хорош для одного разработчика и небольшого модуля. Актуальная линейка на момент написания — mutmut 3.x; синтаксис CLI между 2.x и 3.x менялся, поэтому команды сверяйте с версией, которую поставили.
cosmic-ray
Тяжелее в настройке (нужен TOML-конфиг и явная инициализация сессии), зато рассчитан на распределённый запуск и большие кодовые базы. Ведёт SQLite-базу сессии, что удобно для повторных прогонов и отчётов в CI.
| Критерий | mutmut | cosmic-ray |
|---|---|---|
| Порог входа | Низкий, одна команда | Выше, конфиг + сессия |
| Конфигурация | setup.cfg / pyproject.toml | отдельный TOML-файл |
| Хранение результатов | локальный кеш | SQLite-сессия |
| Распределённый запуск | ограниченно | да, по нескольку воркеров |
| Кому подходит | модуль, один автор | большая база, команда, CI |
Для первого знакомства берите mutmut: результат увидите за один вечер. Точные имена операторов мутаций и флагов проверяйте в документации выбранной версии — наборы мутаций у проектов различаются.
Запуск mutmut на живом модуле
Возьмём маленький модуль с функцией расчёта скидки — как раз тот случай, где «покрытие есть, а проверки нет».
# pricing.py
def apply_discount(price: float, percent: int) -> float:
"""Возвращает цену со скидкой. Скидка от 0 до 90 процентов."""
if percent < 0 or percent > 90:
raise ValueError("percent must be between 0 and 90")
return price * (1 - percent / 100)
А вот тест, который даёт 100% покрытия и при этом почти ничего не проверяет:
# test_pricing.py
from pricing import apply_discount
def test_apply_discount_runs():
# вызвали функцию, но не проверили результат
result = apply_discount(100.0, 20)
assert result is not None
Ставим и запускаем инструмент (пример для mutmut 3.x, синтаксис сверяйте с версией):
pip install mutmut
mutmut run --paths-to-mutate pricing.py
mutmut results
Инструмент сгенерирует мутантов: заменит * на /, 1 - percent/100 на 1 + percent/100, < 0 на <= 0 и так далее. С таким тестом почти все они выживут — assert result is not None проходит и на сломанной формуле. Отчёт покажет список выживших мутантов с указанием строк.
Чиним тест так, чтобы он проверял значение и границы:
# test_pricing.py
import pytest
from pricing import apply_discount
def test_discount_value():
assert apply_discount(100.0, 20) == 80.0
def test_discount_zero():
assert apply_discount(100.0, 0) == 100.0
def test_discount_upper_bound():
assert apply_discount(100.0, 90) == 10.0
def test_discount_too_high():
with pytest.raises(ValueError):
apply_discount(100.0, 91)
Повторный прогон убьёт мутантов на формуле (значение изменится), на границе 90/91 (сработает или не сработает raise) и на знаке скидки. Выжившие оставшиеся — сигнал, где логика всё ещё не закрыта.
Сколько это стоит по времени
Главная цена мутационного тестирования — время. Для каждого мутанта прогоняется набор тестов целиком (или его часть). Если у вас 500 мутантов и тесты идут 10 секунд, наивный прогон — почти полтора часа. Инструменты это частично лечат:
- прогоняют для мутанта только тесты, покрывающие изменённую строку;
- кешируют результаты и при повторном запуске трогают только изменившийся код;
- распределяют мутантов по воркерам (сильная сторона
cosmic-ray).
Конкретные числа зависят от вашего железа, размера базы и скорости тестов — приводить «средние» цифры бессмысленно. Замерьте сами: запустите на одном модуле, засеките время, умножьте на число модулей. Это даст реалистичную оценку до того, как вы попробуете натравить инструмент на весь проект и получите прогон на несколько часов.
Где подход ломается
Мутационное тестирование не бесплатно и не всесильно. Реальные проблемы:
- Эквивалентные мутанты. Иногда мутация не меняет поведение программы (например, замена в мёртвой ветке или в коде, результат которого нигде не используется). Такой мутант выживет всегда, и убить его тестом нельзя — это ложный сигнал, который приходится размечать вручную.
- Долгие и флаки-тесты. Если тесты нестабильны, часть мутантов будет «убиваться» случайно, и score станет шумом. Мутационное тестирование требует детерминированных тестов.
- Медленные интеграционные тесты. Прогонять мутации через тесты, которые ходят в реальную БД или сеть, почти нереально по времени. Метод хорош для чистой логики и юнит-тестов, а не для e2e.
- Соблазн гнать score к 100%. Полная зачистка мутантов на второстепенном коде съедает время без пользы. Целить стоит в бизнес-логику, расчёты, парсинг, права доступа — туда, где цена ошибки высокая.
Как внедрять без боли
Не запускайте мутационное тестирование на всём репозитории в CI с первого дня — прогон встанет колом и команда его отключит. Разумная последовательность:
- Выберите один критичный модуль с чистой логикой (расчёты, валидация, тарификация).
- Прогоните
mutmutлокально, посмотрите на выживших мутантов, дошлифуйте тесты. - Зафиксируйте базовый mutation score этого модуля как ориентир.
- В CI гоняйте мутации не на весь код, а только на изменённые в PR файлы — так прогон остаётся минутным.
- Расширяйте охват модуль за модулем, а не одним махом.
Что делать дальше
Начните с одного модуля бизнес-логики и посмотрите, сколько мутантов выживает при вашем текущем покрытии — цифра обычно отрезвляет. Дальше решите, где мутационное тестирование окупается: на критичных расчётах — да, на CRUD-обёртках — вряд ли. Настройте запуск в CI только по изменённым файлам, чтобы прогон оставался быстрым. И держите тесты детерминированными: без этого score превращается в лотерею. Точный набор операторов мутаций и флаги CLI сверяйте с документацией конкретной версии инструмента — они различаются между релизами.
1 Mutation score — доля убитых мутантов от общего числа жизнеспособных (не считая эквивалентных). Чем выше, тем строже тесты реагируют на изменения кода.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: mutmut — документация проекта, Cosmic Ray — документация проекта
Частые вопросы
Чем мутационное тестирование отличается от покрытия кода?
А если у меня другая версия mutmut, команды не совпадают?
Что делать с мутантами, которых невозможно убить?
Не сработало: прогон идёт часами и не заканчивается. Что не так?
Стоит ли добиваться mutation score 100%?
Можно ли встроить мутационное тестирование в CI?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.