Тестирование ваших тестов

Метрики покрытия кода показывают, какой код выполняют ваши тесты, но не показывают, обнаружат ли тесты реальные баги в этом коде. Тест, который выполняет строку, но не проверяет результат, достигает покрытия без реальной пользы.

Мутационное тестирование переворачивает перспективу: вместо измерения того, сколько кода покрывают тесты, оно измеряет, насколько хорошо тесты обнаруживают дефекты. Для этого оно намеренно вносит баги (мутации) в исходный код и проверяет, ловит ли их тестовый набор.

Если ваши тесты проходят при внесённом баге — эти тесты слабые.

Как работает мутационное тестирование

Процесс следует этим шагам:

  1. Генерация мутантов. Инструмент создаёт копии исходного кода, каждая с одним небольшим изменением (мутацией). Каждая изменённая копия — мутант.

  2. Запуск тестов на каждом мутанте. Полный тестовый набор запускается на каждом мутанте.

  3. Классификация результатов:

    • Убитый мутант (killed) — хотя бы один тест падает (хорошо — тесты обнаружили дефект)
    • Выживший мутант (survived) — все тесты проходят (плохо — тесты пропустили дефект)
    • Эквивалентный мутант — мутация не меняет поведение программы (нейтрально)
  4. Расчёт 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

Для каждого мутанта предскажите результат (убит/выжил):

  1. Заменить price * 0.8 на price * 0.9
  2. Заменить price * 0.9 на price * 0.8
  3. Заменить customer_type == "premium" на customer_type != "premium"
  4. Заменить return price на return 0
  5. Заменить price * 0.8 на price + 0.8
Решение
  1. Убит. test_premium_discount ожидает 80, получает 90.
  2. Убит. test_regular_discount ожидает 90, получает 80.
  3. Убит. test_premium_discount попадает в неправильную ветвь.
  4. Убит. test_no_discount ожидает 100, получает 0.
  5. Убит. 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"
  1. Заменить age >= 18 на age > 18
  2. Заменить income > 30000 на income >= 30000
  3. Заменить has_account на not has_account
  4. Заменить return "PENDING" на return "APPROVED"
  5. Заменить and на or
Решение
  1. Выжил. Тест использует age=25, что удовлетворяет и >= 18, и > 18.
  2. Выжил. Тест использует income=50000, что удовлетворяет и > 30000, и >= 30000.
  3. Убит. test_approved теперь попадает в ветвь else и возвращает “PENDING”.
  4. Выжил. Ни один тест не достигает return "PENDING".
  5. Выжил. С or оба теста дают те же результаты.

Mutation score: 1/5 = 20%. Для улучшения: добавить граничные тесты и покрыть путь “PENDING”.

Эквивалентные мутанты: проблема

Эквивалентный мутант даёт тот же результат для всех входных данных. Обнаружение эквивалентных мутантов неразрешимо в общем случае (эквивалентно проблеме останова). Современные инструменты используют эвристики для их выявления.

Интеграция мутационного тестирования в CI/CD

  1. Начните с критичных модулей. Не запускайте на всём codebase сразу.
  2. Установите порог. Фейлите билд при падении mutation score ниже цели (например, 80%).
  3. Запускайте инкрементально. Мутируйте только код из текущего PR.
  4. Используйте для code review. Делитесь отчётами с ревьюерами.

Ключевые выводы

  • Мутационное тестирование оценивает качество тестов, внося намеренные дефекты в исходный код
  • Убитые мутанты = хорошие тесты; выжившие = пробелы в тестировании
  • Mutation score = убитые / (всего - эквивалентные) * 100%
  • Типичные операторы: замена арифметических, сравнительных, логических операторов; удаление; мутация возвратов
  • Инструменты: PIT (Java), Stryker (JS/TS), mutmut (Python), Infection (PHP)
  • Мутационное тестирование затратно — используйте инкрементальные и параллельные стратегии
  • Эквивалентные мутанты нельзя убить — исключайте из подсчёта
  • Стремитесь к 80%+ mutation score на критичной бизнес-логике