Принудительное единое покрытие мутациями для всей кодовой базы — отличный способ заставить вашу команду ненавидеть тестирование.

Запустите PIT или Stryker на типовом репозитории, и вы увидите одну и ту же картину: модули аутентификации набирают 40%, строковые утилиты достигают 95%, а ваш ORM-слой где-то в районе 60. Рефлекторная реакция — установить глобальный порог, скажем, на 70% и блокировать каждый PR, который опускается ниже. Через два спринта кто-то отключает проверку в CI и списывает всё на «ненадёжные мутаторы».

Настоящая проблема не в инструментах. Она в том, что мы притворяемся, будто весь код имеет одинаковый радиус поражения, когда мутант выживает.

Что на самом деле измеряет мутационное тестирование

Покрытие кода говорит, какие строки выполнялись. Мутационное тестирование говорит, заметят ли ваши тесты, если эти строки изменятся.

Мутационный фреймворк вносит небольшие дефекты (мутанты) в ваш исходный код. Он может заменить > на <, удалить вызов метода или изменить возвращаемое значение. Если ваш набор тестов ловит изменение, мутант убит. Если тесты всё равно проходят, мутант выживает. Ваш mutation score — это процент убитых мутантов.

Выживший мутант в сравнении хеша пароля — это баг безопасности, готовый попасть в продакшн. Выживший мутант в хелпере capitalizeFirstLetter — в худшем случае слегка странная метка в интерфейсе.

Относиться к ним одинаково — вот где команды ошибаются.

Почему auth заслуживает порога 90%+

Код аутентификации и авторизации обладает двумя свойствами, которые делают его идеальным для агрессивного мутационного тестирования.

Во-первых, логика обычно дискретна и похожа на конечный автомат. Токен истёк? Роль входит в разрешённый набор? Подпись проверена? Каждая ветка имеет чёткое значение для безопасности, и каждая должна быть протестирована.

Во-вторых, цена выжившего мутанта катастрофична. Один перепутанный булевый флаг в проверке роли может открыть админские эндпоинты. Пропущенный not в процедуре валидации токена может привести к приёму поддельных JWT. Это не теория. Базы данных CVE полны обходов аутентификации, вызванных логическими ошибками, которые мутационное тестирование поймало бы.

В Sentry мы требуем 90% mutation score для всего в модулях authn/ и authz/. Всё, что ниже, падает в CI. Никаких исключений, никаких «исправим в следующем спринте». Модуль достаточно мал, чтобы этого можно было достичь, не писав 40 строк тестов на каждую строку продакшн-кода.

Вот как это выглядит на практике. Это упрощённая процедура валидации JWT:

import time
from typing import Optional

def verify_token(token: dict, expected_aud: str, leeway: int = 30) -> bool:
    now = time.time()

    if token.get("aud") != expected_aud:
        return False

    exp = token.get("exp")
    if exp is not None and now > exp + leeway:
        return False

    return True

Мутационный фреймворк может заменить > на >= в проверке срока действия. Без теста, который использует токен, истекающий ровно в now + leeway, этот мутант выживет. Это означает, что ваши тесты на самом деле не проверяют границу. При 90% покрытии мутациями такой тест существует.

Утилитарный код может обойтись 60%

Ваши StringUtils, DateHelpers и MathExtensions — противоположный конец спектра.

Эти модули обычно чистые, активно переиспользуемые и легко поддающиеся анализу. Выживший мутант в truncate(str, maxLen), который меняет > на >=, может обрезать один лишний символ. Это странность интерфейса, а не инцидент безопасности.

Математика риска и выгоды меняется. В этих модулях часто десятки маленьких функций. Погоня за 90% покрытием мутациями означает написание тестов для каждого off-by-one варианта в padLeft. Тесты становятся длиннее защищаемого ими кода, и бремя поддержки начинает перевешивать ценность.

Мы устанавливаем минимум 60% для утилитарных модулей. Это ловит очевидные пробелы (пропущенные проверки на null, неправильные возвращаемые значения), не заставляя команду исчерпывающе тестировать каждую перестановку нарезки строк.

Ключ в честности относительно того, что означает 60%. Это означает «мы протестировали типичные случаи и очевидные сбои». Это не означает «этот код не важен». Если утилитарная функция используется на чувствительном к безопасности пути, она наследует более высокий порог от своего потребителя.

Золотая середина: бизнес-логика

Большая часть вашего кода находится между этими полюсами. Обработка платежей, валидация данных, оркестрация рабочих процессов. Эти модули влияют на корректность и доверие пользователя, но один выживший мутант обычно не отдаст злоумышленнику вашу базу данных.

Мы используем многоуровневую систему:

Тип модуляПорог мутацийОбоснование
AuthN / AuthZ90%Высокий радиус поражения, дискретная логика
Business logic75%Критично для корректности, умеренная сложность
Utilities / helpers60%Низкий радиус поражения, высокое переиспользование, простые функции
Generated / boilerplateExcludedНе тестируйте код, который вы не писали

Это не жёсткое правило. Модуль расчёта платежей может повыситься до 85%. Широко используемый JSON-хелпер может вырасти до 75%, если его потребляет auth-код. Уровни — отправная точка, а не клетка.

Как реализовать многоуровневые пороги мутаций

Stryker и PIT оба поддерживают конфигурацию для каждого модуля. Вот как мы интегрируем это в Python-проект с помощью mutmut и кастомной конфигурации:

# mutation_config.py
THRESHOLDS = {
    "src/authn/": 90,
    "src/authz/": 90,
    "src/billing/": 85,
    "src/workflows/": 75,
    "src/utils/": 60,
}

EXCLUDE_PATHS = [
    "src/generated/",
    "src/migrations/",
]

В CI небольшой скрипт читает эту конфигурацию и запускает мутационный тестер для каждого модуля:

#!/usr/bin/env bash
# ci/check-mutation.sh
set -e

python -m mutmut run --paths-to-mutate=src/authn/
python -m mutmut results || true
python -m mutmut run --paths-to-mutate=src/utils/
python -m mutmut results || true

python ci/verify_thresholds.py

Скрипт верификации проверяет score каждого модуля против его порога. Если src/authn/ набирает 87%, сборка падает с чётким сообщением: authn/ scored 87%, threshold is 90%.

Для Stryker (JavaScript/TypeScript) используйте stryker.conf.js с группами мутаторов:

// stryker.conf.js
module.exports = {
  thresholds: {
    high: 90,
    low: 75,
    break: null, // we handle this per-module
  },
  mutate: [
    "src/auth/**/*.ts",
    "src/billing/**/*.ts",
    "src/utils/**/*.ts",
  ],
  ignorePatterns: ["src/generated/**"],
};

Мы оборачиваем Stryker в скрипт, который запускает его трижды с разными path globs и применяет порог для каждой директории после каждого запуска. Это немного неуклюже, но работает.

Ловушка погони за 100%

Некоторые команды воспринимают мутационное тестирование как игру, которую нужно выиграть. Они пишут тесты, которые существуют только для убийства мутантов, а не для проверки поведения.

Худший пример — тестирование того, что конкретное сообщение об исключении содержит подстроку, только чтобы мутант, который меняет текст сообщения, был убит. Такой тест не добавляет ценности. Он не проверяет, что исключение выбрасывается в нужный момент или что выбрасывается правильный тип. Он проверяет только строку.

Если вы ловите себя на том, что пишете тесты только для того, чтобы подвинуть процент, вы перевернули цель. Мутационное тестирование — диагностический инструмент, а не таблица лидеров. Score говорит, куда смотреть. Он не говорит, когда всё готово.

Чему мы научились на своём горьком опыте

Мы начали с глобального порога 80%. За месяц три команды отключили его в фича-ветках «временно». Два из этих временных отключений стали постоянными.

Проблема была не в числе. Она была в том, что 80% было слишком мало для auth-кода (мы упустили баг проверки роли, который дошёл до staging) и слишком много для 4000-строчного утилитарного модуля (команда потратила две недели на написание тестов для вариантов isValidEmail).

После того как мы разделили на уровни, внедрение прижилось. Команды auth приняли планку 90%, потому что область была ограничена. Платформенные команды приняли 60% для утилит, потому что это было достижимо без безумия. Многоуровневый подход превратил мутационное тестирование из наказания в разговор о риске.

С чего начать

Если вы внедряете мутационное тестирование в существующую кодовую базу, не устанавливайте никаких порогов на первой неделе. Запустите инструмент, посмотрите на score и спросите: где выживший мутант причинит больше всего вреда?

Начните с auth. Установите там 90%, добейтесь зелёного статуса и докажите ценность. Расширяйтесь на бизнес-логику, когда команда поверит сигналу. Держите утилиты на более низкой планке или исключите их полностью, пока вы не выработаете привычку.

И помните: 60% score с честными тестами побеждает 95% score с тестами, написанными для обмана мутатора. Цель — ловить реальные баги, а не впечатлять панель метрик.

Если хотите попробовать сами, mutmut для Python и Stryker для JavaScript оба поддерживают описанные выше паттерны для каждой директории. Начните с малого. Один auth-модуль. Одна неделя. Посмотрите, что выживет.