Прямой ответ
Снимковое тестирование (snapshot testing) сравнивает результат работы кода с сохранённым эталоном — файлом, который лежит рядом с тестами и проверяется в репозитории. При первом прогоне эталон создаётся, при следующих — сверяется. Отличие от обычного теста в том, что ожидаемое значение не пишут вручную: его фиксируют один раз как «то, что получилось».
Как это выглядит:
# tests/test_invoice_snap.py (syrupy)
def test_invoice_snapshot(snapshot, invoice_factory):
invoice = invoice_factory(items=3, discount=0.1)
assert render_invoice(invoice) == snapshot
Эталон сохраняется в каталоге __snapshots__/ в формате .ambr, .json или .txt и попадает в git вместе с тестом. При расхождении тест падает и показывает diff.
В чём реальная польза:
- Дешёвая фиксация сложного вывода. Отчёт, HTML, сериализованная структура с десятками полей — описывать их вручную долго и скучно.
- Защита от незапланированных изменений. Любое изменение формата становится явным: разработчик видит diff и решает, ожидаемое оно или нет.
- Документирование текущего поведения. Эталон показывает, что именно генерирует код, без чтения реализации.
В чём главный риск: тест ничего не утверждает о правильности. Он утверждает, что вывод не изменился. Если первая зафиксированная версия была ошибочной, тест будет защищать ошибку. Отсюда правило: эталон читают и проверяют перед первым коммитом.
Вторая проблема — лёгкость обновления. Команда --snapshot-update перезаписывает эталоны пакетом. Если делать это не глядя, тесты превращаются в формальность, которая всегда проходит.
Практика: правила применения
1. Фиксируйте стабильное и детерминированное. Снимок не должен зависеть от времени, порядка словарей, локали, случайных идентификаторов и версии библиотеки. Иначе тест станет источником нестабильности — см. flaky-тесты.
def test_report_snapshot(snapshot, frozen_time, invoice_factory):
# время заморожено, идентификаторы детерминированы, сортировка ключей явная
report = build_report(invoice_factory(), now=frozen_time)
assert normalize(report) == snapshot
2. Нормализуйте то, что меняться должно. Даты создания, UUID, абсолютные пути, номера строк — заменяйте плейсхолдерами до сравнения. Снимок проверяет структуру, а не случайно сгенерированные значения.
3. Держите снимки небольшими. Снимок на 800 строк невозможно просмотреть при изменении. Лучше несколько снимков по отдельным частям вывода, чем один огромный.
4. Обновляйте осознанно. Обновление эталона — это утверждение «новое поведение верно». В код-ревью diff эталона должен быть виден и объяснён так же, как изменение кода.
5. Не заменяйте снимками проверки логики. Расчёт денег, прав доступа, состояний проверяют явными утверждениями. Снимок уместен для формы вывода: разметка, сериализация, текстовые отчёты, CLI-вывод.
6. Снимок ловит не всё. Мутация, меняющая значение внутри большого вывода, будет замечена, а мутация, меняющая ветку, которая в этом сценарии не выполняется, — нет. Оценка защищённости — в статье про мутационное тестирование.
Рекомендации:
- Выбирайте формат эталона под задачу. Текстовый diff читается лучше, чем бинарный; для структур данных удобнее JSON с сортировкой ключей.
- Храните эталоны в репозитории. Это часть тестов; без них тест не воспроизводится. Каталог с эталонами не должен попадать в
.gitignore. - Свяжите обновление с причиной. Коммит, который меняет только эталоны без изменения кода, — подозрительный сигнал.
- Ограничьте число снимков в проекте. Десятки снимков на модуль означают, что тесты перестали описывать поведение и начали дублировать реализацию.
- Проверяйте читаемость diff. Если при изменении одного поля diff показывает перестановку всего файла, формат эталона выбран неудачно.
Практический сценарий: снимки защищали ошибку полгода
Контекст: модуль формирования платёжных документов был покрыт снимочными тестами. Все прогоны проходили, но в проде обнаружили неверный расчёт НДС в одной ветке.
- Диагностика. Ветка с особым режимом налогообложения попадала в снимок, и эталон содержал неверное значение. Тест исправно сверял вывод с этим эталоном и проходил.
- Причина. Эталон зафиксировали автоматически на первом прогоне, никто не читал содержимое. Обновления позже тоже применяли пакетно.
- Что ещё нашли. Проверка показала, что 11 из 26 снимков содержали значения, которые никто не верифицировал; в трёх из них были устаревшие реквизиты.
- Действия. (а) Для денежных расчётов добавили явные утверждения, снимки оставили только для формы документа; (б) ввели правило: добавление или обновление эталона требует объяснения в описании коммита; (в) нормализовали даты и идентификаторы, чтобы снимки не менялись без причины; (г) убрали 7 снимков, дублировавших друг друга.
- Результат. Число снимков сократилось с 26 до 15, время разбора падения уменьшилось, а расчёт денег перестал зависеть от эталонных файлов.
Вывод: снимочный тест защищает от изменений, но не от ошибок. Там, где важна корректность значения, нужна явная проверка; снимок отвечает за форму.
FAQ
Чем снимочный тест отличается от параметризованного с ожидаемым значением? Ожидаемое значение в параметризованном тесте пишет человек, исходя из требований. В снимочном тесте эталон берётся из фактического вывода программы. Первое проверяет соответствие требованиям, второе — неизменность поведения.
Стоит ли использовать снимки для API-ответов? Частично. Форма ответа — да, конкретные значения — нет, иначе тест будет падать на каждом изменении данных. Обычно снимают структуру с подменой значений плейсхолдерами, а отдельные поля проверяют явно.
Что делать при частых конфликтах в git по файлам эталонов? Выбрать формат с построчной структурой и стабильной сортировкой, разбить большие снимки на части, избегать хранения нескольких снимков в одной строке. Конфликты обычно возникают из-за неупорядоченных структур.
Есть ли альтернатива снимкам для проверки структуры? Да: проверка схемы (JSON Schema, pydantic-модель) описывает допустимую структуру явно и не ломается при изменении значений. Это более строгий и более читаемый способ, если структура формализуема.
Источники: pytest docs, syrupy, Python difflib — ссылки в шапке.