Мутационная проверка тестов
Зелёный тест ничего не доказывает, пока не показано, что он умеет краснеть на сломанном коде. Ручной вариант мутационного тестирования: на каждый защитный механизм в коде — копия модуля со сломанным ровно этим механизмом, и тест-сьют обязан её поймать. Дёшево (скрипт на полчаса), а ловит именно тесты-пустышки.
Рецепт канарейки
- Для каждого guard’а в коде — текстовая мутация копии модуля (репозиторный файл не трогается) с assert’ом «якорь-строка встретилась ровно один раз» — иначе мутация молча не применяется и канарейка врёт.
- Прогнать сьют против каждой мутанта: каждая мутация обязана уронить хотя бы один тест. Выживший мутант = дыра в тестах.
- Отчёт — таблица «сломано → какие тесты упали».
Пример из моей практики (тесты поллера датчика, 6 мутаций): убрана проверка поколения greenlet’а → падает тест гонки рестарта; сеттер стоп-флага не гасит поллер → падают 12 из 14; «захват слота» всегда True → падает тест двойного старта; исключение читалки пробрасывается → 2 теста; тарирование средним вместо медианы → 2 теста (медиана против среднего разведены спайком +250 000 в тихом окне: медиана даёт базлайн, среднее уехало бы на 25 000); окно игнорирует нижнюю границу времени → 2 теста. Выживших нет.
Канарейка ловит дыры в самих тестах
Первый прогон моей канарейки поймал дыру в моём же тесте: мутация «убрана проверка поколения» ВЫЖИВАЛА, потому что в тестовом сценарии вытесняющий поллер успевал остановиться до пробуждения вытесненного — тот выходил по другому флагу, и проверяемый механизм был ни при чём. Тест зеленел и на сломанном коде. Пришлось перестроить сценарий так, чтобы старый greenlet просыпался при живом новом — тогда остановить его может ТОЛЬКО проверяемый штамп поколения. Это в точности «проверка обязана уметь провалиться»: тест, который не может упасть от поломки механизма, механизм не проверяет.
Предохранитель — вне проверяемого механизма
Вторая находка того же прогона: таймаут-предохранитель от зависшего цикла я сперва построил на том же стоп-флаге, который ломает одна из мутаций — канарейка повисла намертво вместо падения. Правило: предохранитель регресса строится на механизме, НЕЗАВИСИМОМ от проверяемых (у меня — счётчик итераций фейкового реактора). И тонкость исключений: предохранитель бросает наследника BaseException, а не Exception — иначе except Exception внутри проверяемого кода (у поллера он по замыслу глотает всё) съест и предохранитель. Итог: регресс падает за 0.03 с с внятным ReactorRunaway: no loop stopped after 401 pauses вместо зависшего pytest.
Где это дёшево и оправдано
- Код с невоспроизводимыми в юнитах гонками (greenlet’ы, таймеры): детерминированный фейк времени (часы двигаются только явным
pause) + канарейки на каждый guard. - Чужой/вендорный код без тестов: канарейка на свои правки в нём.
- Родственный приём для данных вместо кода — Офлайн-replay стенд для прод-анализа.