Как покрыть тестами легаси-код: характеризационные тесты и швы
Продвинутое3 минРедакция Innovialabs
Коротко
Сначала зафиксируйте текущее поведение характеризационными или snapshot-тестами, затем через швы отрежьте базу, сеть и время, и только после этого меняйте код маленькими проверенными шагами.
Главная идея
В легаси-коде нельзя сразу писать тесты «как должно быть»: никто точно не знает, как должно быть, а код уже работает у пользователей. Поэтому порядок другой:
- Зафиксировать текущее поведение — даже странное.
- Найти швы, чтобы изолировать код от базы, сети, времени и файлов.
- Менять код маленькими шагами, запуская тесты после каждого.
Цель первых тестов — не доказать правильность, а получить сигнализацию: если поведение изменилось, вы узнаете об этом сразу.
Характеризационные тесты
Характеризационный тест описывает, что код делает сейчас. Техника простая:
- Вызовите функцию с реальными входными данными.
- Напишите проверку с заведомо неверным ожидаемым значением.
- Запустите тест, посмотрите фактический результат и вставьте его в ожидание.
Если результат кажется ошибкой, не исправляйте его сразу. Оставьте тест как есть, добавьте комментарий и заведите задачу: исправление бага — отдельное, осознанное изменение.
Выбирайте входы, которые проходят по разным веткам. Отчёт о покрытии (coverage) подскажет, какие ветки ещё не задеты.
Snapshot и approval testing
Когда результат большой — HTML, JSON, отчёт, PDF в текстовом виде, — писать ожидания вручную долго. Здесь помогают:
- Snapshot-тесты — Jest
toMatchSnapshot, в Python, например, syrupy. Первый запуск сохраняет результат, следующие сравнивают с ним. - Approval testing — библиотеки семейства ApprovalTests. Результат пишется в файл
received, вы просматриваете diff и «одобряете» его, превращая вapproved. - Golden master — прогон множества входов (например, выгрузки реальных данных без персональной информации) с сохранением всех выходов.
Следите, чтобы в снимок не попадали изменчивые данные: текущее время, случайные ID, порядок ключей. Их нужно подменять или нормализовать — иначе тесты будут «мигать».
Швы и разрыв зависимостей
Шов (seam) — место, где можно изменить поведение кода, не редактируя его в этом месте. Основные виды:
| Шов | Как выглядит |
|---|---|
| Объектный | передать в конструктор или функцию другой объект |
| Модульный | подменить импорт: jest.mock, monkeypatch в pytest |
| Конфигурационный | другой URL базы или флаг окружения для тестов |
Типичный легаси-код сам тянет зависимости:
def send_reminders():
for inv in db.query_unpaid():
if inv.due_date < datetime.now() - timedelta(days=7):
mailer.send(inv.email, render(inv))
Безопасный приём — извлечь параметры и оставить старую функцию обёрткой, чтобы не трогать вызывающий код:
def send_reminders_core(invoices, now, send):
for inv in invoices:
if inv.due_date < now - timedelta(days=7):
send(inv.email, render(inv))
def send_reminders():
send_reminders_core(db.query_unpaid(), datetime.now(), mailer.send)
Теперь send_reminders_core тестируется со списком в памяти, фиксированной датой и фейковой функцией отправки. Такие механические шаги лучше делать средствами IDE — автоматический рефакторинг меньше рискует ошибиться.
Ещё два полезных приёма:
- Sprout method — новую логику пишите в отдельной функции с тестами и вызывайте из старого кода.
- Wrap method — оборачиваете старую функцию новой, которая добавляет поведение до или после.
Рефакторинг малыми шагами
- Одно изменение — один запуск тестов — один коммит.
- Не смешивайте рефакторинг и изменение поведения в одном коммите.
- Если тест упал и причина непонятна, откатитесь, а не чините вперёд.
- По мере того как код становится понятнее, заменяйте снимки точными unit-тестами с осмысленными проверками.
- Начинайте с участков, которые чаще всего меняются или ломаются, а не со всего проекта сразу.
Типичные ошибки
- Большой рефакторинг «заодно» до появления тестов.
- Снимки, которые обновляют не глядя, — тогда они ничего не защищают.
- Моки на каждый вызов, из-за которых тест повторяет реализацию и ломается от любой правки.