Тестирование ваших тестов
Метрики покрытия кода показывают, какой код выполняют ваши тесты, но не показывают, обнаружат ли тесты реальные баги в этом коде. Тест, который выполняет строку, но не проверяет результат, достигает покрытия без реальной пользы.
Мутационное тестирование переворачивает перспективу: вместо измерения того, сколько кода покрывают тесты, оно измеряет, насколько хорошо тесты обнаруживают дефекты. Для этого оно намеренно вносит баги (мутации) в исходный код и проверяет, ловит ли их тестовый набор.
Если ваши тесты проходят при внесённом баге — эти тесты слабые.
Как работает мутационное тестирование
Процесс следует этим шагам:
Генерация мутантов. Инструмент создаёт копии исходного кода, каждая с одним небольшим изменением (мутацией). Каждая изменённая копия — мутант.
Запуск тестов на каждом мутанте. Полный тестовый набор запускается на каждом мутанте.
Классификация результатов:
- Убитый мутант (killed) — хотя бы один тест падает (хорошо — тесты обнаружили дефект)
- Выживший мутант (survived) — все тесты проходят (плохо — тесты пропустили дефект)
- Эквивалентный мутант — мутация не меняет поведение программы (нейтрально)
Расчёт mutation score. Mutation score = убитые / (всего - эквивалентные) * 100%
Типичные операторы мутации
Замена арифметических операторов
# Оригинал
total = price * quantity
# Мутант
total = price + quantity
Замена операторов сравнения
# Оригинал
if age >= 18:
# Мутант
if age > 18:
Замена логических операторов
# Оригинал
if is_active and is_verified:
# Мутант
if is_active or is_verified:
Замена констант
# Оригинал
MAX_RETRIES = 3
# Мутант
MAX_RETRIES = 0
Удаление операторов
# Оригинал
def process(data):
validate(data) # Эта строка удалена в мутанте
transform(data)
save(data)
Мутация возвращаемых значений
# Оригинал
return True
# Мутант
return False
Эффект связывания и гипотеза компетентного программиста
Мутационное тестирование основано на двух теоретических принципах:
Гипотеза компетентного программиста: Программисты создают код, близкий к правильному. Реальные баги — обычно мелкие ошибки: неверный оператор, ошибка на единицу, пропущенное отрицание.
Эффект связывания: Тесты, обнаруживающие простые дефекты (мутанты первого порядка), также обнаружат более сложные (мутанты высших порядков).
Интерпретация mutation score
| Mutation Score | Оценка |
|---|---|
| 90-100% | Отлично — тесты ловят почти все дефекты |
| 75-89% | Хорошо — есть слабости для исправления |
| 60-74% | Удовлетворительно — значительные пробелы |
| Ниже 60% | Плохо — тесты дают ложную уверенность |
Инструменты мутационного тестирования
PIT (PITest) — Java
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
</plugin>
Stryker — JavaScript/TypeScript
npm install --save-dev @stryker-mutator/core
npx stryker init
npx stryker run
Другие инструменты
- mutmut — мутационное тестирование для Python
- Infection — мутационное тестирование для PHP
- cosmic-ray — ещё один мутационный тестер для Python
- cargo-mutants — мутационное тестирование для Rust
Вопросы производительности
Мутационное тестирование вычислительно затратно. Стратегии управления:
Инкрементальное мутационное тестирование. Мутируйте только изменённые файлы.
Выборка тестов. Запускайте только релевантные тесты.
Параллельное выполнение. Запускайте мутанты параллельно.
Сэмплирование. Тестируйте случайное подмножество мутантов.
Приоритизация. Запускайте на бизнес-критичных модулях.
Упражнение: Анализ результатов мутации
Задача 1
Дана функция и её тесты:
def calculate_discount(price, customer_type):
if customer_type == "premium":
return price * 0.8
elif customer_type == "regular":
return price * 0.9
else:
return price
def test_premium_discount():
assert calculate_discount(100, "premium") == 80
def test_regular_discount():
assert calculate_discount(100, "regular") == 90
def test_no_discount():
assert calculate_discount(100, "guest") == 100
Для каждого мутанта предскажите результат (убит/выжил):
- Заменить
price * 0.8наprice * 0.9 - Заменить
price * 0.9наprice * 0.8 - Заменить
customer_type == "premium"наcustomer_type != "premium" - Заменить
return priceнаreturn 0 - Заменить
price * 0.8наprice + 0.8
Решение
- Убит.
test_premium_discountожидает 80, получает 90. - Убит.
test_regular_discountожидает 90, получает 80. - Убит.
test_premium_discountпопадает в неправильную ветвь. - Убит.
test_no_discountожидает 100, получает 0. - Убит.
test_premium_discountожидает 80, получает 100.8.
Все мутанты убиты — mutation score: 100%.
Задача 2
Функция со слабыми тестами:
def is_eligible(age, income, has_account):
if age >= 18 and income > 30000:
if has_account:
return "APPROVED"
else:
return "PENDING"
return "REJECTED"
def test_approved():
result = is_eligible(25, 50000, True)
assert result == "APPROVED"
def test_rejected():
result = is_eligible(16, 20000, False)
assert result == "REJECTED"
- Заменить
age >= 18наage > 18 - Заменить
income > 30000наincome >= 30000 - Заменить
has_accountнаnot has_account - Заменить
return "PENDING"наreturn "APPROVED" - Заменить
andнаor
Решение
- Выжил. Тест использует age=25, что удовлетворяет и
>= 18, и> 18. - Выжил. Тест использует income=50000, что удовлетворяет и
> 30000, и>= 30000. - Убит.
test_approvedтеперь попадает в ветвьelseи возвращает “PENDING”. - Выжил. Ни один тест не достигает
return "PENDING". - Выжил. С
orоба теста дают те же результаты.
Mutation score: 1/5 = 20%. Для улучшения: добавить граничные тесты и покрыть путь “PENDING”.
Эквивалентные мутанты: проблема
Эквивалентный мутант даёт тот же результат для всех входных данных. Обнаружение эквивалентных мутантов неразрешимо в общем случае (эквивалентно проблеме останова). Современные инструменты используют эвристики для их выявления.
Интеграция мутационного тестирования в CI/CD
- Начните с критичных модулей. Не запускайте на всём codebase сразу.
- Установите порог. Фейлите билд при падении mutation score ниже цели (например, 80%).
- Запускайте инкрементально. Мутируйте только код из текущего PR.
- Используйте для code review. Делитесь отчётами с ревьюерами.
Ключевые выводы
- Мутационное тестирование оценивает качество тестов, внося намеренные дефекты в исходный код
- Убитые мутанты = хорошие тесты; выжившие = пробелы в тестировании
- Mutation score = убитые / (всего - эквивалентные) * 100%
- Типичные операторы: замена арифметических, сравнительных, логических операторов; удаление; мутация возвратов
- Инструменты: PIT (Java), Stryker (JS/TS), mutmut (Python), Infection (PHP)
- Мутационное тестирование затратно — используйте инкрементальные и параллельные стратегии
- Эквивалентные мутанты нельзя убить — исключайте из подсчёта
- Стремитесь к 80%+ mutation score на критичной бизнес-логике