Небольшой инженерный постмортем про то, как метрики качества тестов дружно молчали, пока пользователь не прислал скриншот с очевидной ошибкой. И почему ни строчное покрытие, ни мутационное тестирование этот класс багов поймать не могли в принципе.
У меня есть внутренний дашборд, который собирает список рабочих копий проекта (git worktree) и показывает по каждой последнюю активность. В какой-то момент пришло сообщение: «разные ветки, а последнее сообщение и время везде одинаковые». Я открыл дашборд — и правда: каждая копия отображалась пять раз подряд, с идентичными данными. При этом тесты были зелёные, строчное покрытие — сто процентов, а мутационный прогон по этому куску показывал 97,8%. То есть все три метрики, которым принято доверять, дружно сказали «всё хорошо».
Что на самом деле сломалось
Логика сбора состояла из трёх функций. Первая обходила каталог с репозиториями и оставляла те, где есть .git. Вторая для каждого найденного репозитория вызывала git worktree list и парсила вывод. Третья складывала всё в один список для отображения.
Тонкость в том, что у обычного репозитория .git — это директория, а у привязанного worktree — файл-указатель на общий служебный каталог. Проверка «существует ли .git» отвечала «да» в обоих случаях. В результате каждая из пяти копий считалась самостоятельным репозиторием. А git worktree list, запущенный из любой копии, возвращает полный список всех пяти. Пять «репозиториев», у каждого по пять worktree — на выходе двадцать пять строк, где каждая копия честно повторялась пять раз.
Самое неприятное здесь: каждая из трёх функций по отдельности абсолютно корректна. Первая правильно находит репозитории. Вторая правильно парсит вывод git. Третья правильно склеивает. Ошибки нет ни в одной из них — она в стыке, в неявном предположении «раз есть .git, значит это главная копия», которое нигде не было выражено ни в коде, ни в тестах.
Почему все метрики молчали
Разберём по очереди, что именно проверяли зелёные тесты.
Строчное покрытие. Оно фиксирует факт «строка исполнилась во время теста», и ничего не говорит о том, был ли проверен результат её работы. Можно прогнать каждую строку функции и не сделать ни одного содержательного утверждения о её выходе. У функции, вокруг которой крутился баг, покрытие было полное — но проверялось поведение отдельных листовых функций, а не форма итогового списка.
Мутационное тестирование. Здесь стоит остановиться подробнее, потому что 97,8% выглядят убедительно. Мутатор берёт рабочий код, вносит по одной точечной правке (меняет > на >=, убирает условие, инвертирует знак) и смотрит, упадёт ли хоть один тест. Высокий процент означает, что тесты замечают порчу существующего кода. Но этот прогон гонялся по вспомогательной функции кодирования путей — коду, который к багу отношения не имеет вообще. Хороший mutation score на изолированной листовой функции создаёт ложное чувство защищённости всего модуля.
Юнит-тесты. Их было десять, и все — про отдельные листовые операции: один вход, один ожидаемый выход. Ни один не вызывал сборку списка целиком и не проверял свойство результата вида «одна копия встречается не больше одного раза». Покрытие по строкам у листовых функций — почти сто процентов. Покрытие по инвариантам у функции сборки — ноль.
Почему mutation не мог его поймать
Здесь ключевой момент, ради которого я и пишу. Правильное исправление — сделать проверку строже: не «.git существует», а «.git — это директория». В коде это разница между path.exists() и path.is_dir().
Мутационное тестирование работает ровно в обратную сторону: оно портит уже написанный код и проверяет, заметят ли это тесты. Но недостающего условия в коде не было — портить было нечего. Мутатор умеет находить потерю существующего поведения и структурно слеп к отсутствию нужного. Он не предложит вам добавить проверку, которой у вас нет. Ровно поэтому целый класс багов — «каждая функция корректна, сломана их композиция» — проходит и сквозь покрытие, и сквозь мутации.
Что реально поймало бы этот баг
Не процент, а утверждение о форме результата, которое обязано держаться при любом входе. Для списка сущностей это чаще всего инвариант уникальности:
def test_index_has_no_duplicate_worktrees():
# 1 главная копия + 2 привязанных worktree
idx = build_worktree_index(fixture_with(1, linked=2))
paths = [row["worktree_path"] for row in idx["rows"]]
# ожидаем ровно 3 строки, а не 9
assert len(paths) == len(set(paths)), f"дубликаты: {paths}"
Такой тест не зависит от конкретных значений — он проверяет свойство: один и тот же путь не может встретиться дважды. Я завёл четыре подобных инварианта на форму результата, и все четыре красные на старом коде и зелёные после фикса. Полезная эвристика: как только функция возвращает коллекцию и внутри есть вложенный цикл по двум множествам, стоит сразу выписать ожидаемую мощность результата и проверку на дубликаты — это те самые баги композиции, которые метрики по коду не видят.
Выводы, к которым я пришёл
Строчное покрытие отвечает на вопрос «исполнилось ли», а не «проверено ли». Сто процентов покрытия ничего не гарантируют о содержании проверок.
Мутационное тестирование — отличный инструмент полировки зрелого кода, но он находит регрессии, а не пропущенные условия и не ошибки композиции. Не стоит принимать высокий mutation score за доказательство качества.
Против багов композиции работают инварианты на форму результата: уникальность, сохранение количества, ожидаемая мощность, идемпотентность. Один такой assert стоит десятка листовых юнит-тестов.
Прежде чем полировать mutation score, стоит убедиться, что есть хотя бы один сквозной тест на весь пайплайн — иначе полируется то, что и так работает, а дыры остаются в стыках.
Баг оказался безобидным по последствиям (кривой список на дашборде), но показательным по природе. Зелёные метрики — это не «тесты хорошие», это «тесты не заметили того, что не умели заметить».
Apoheliy
Тесты умеют и могут заметить. Системные тесты умеют и могут заметить - они для этого и существуют.