В проекте на React обновили одну кнопку в общей библиотеке компонентов, и конвейер покраснел в двадцати файлах. Ни одна страница не сломалась, пользователь не заметил ровным счётом ничего, а на правки ушёл рабочий день. Виноваты снимки, которые когда-то щедро расставили по всему коду и которые теперь ловят не ошибки, а любые прикосновения к разметке. В соседнем репозитории тот же инструмент ведёт себя противоположно: в ревью тихо подсвечивает, что у общей кнопки уехал межстрочный интервал на полпикселя. Разница не в инструменте, разница в границах его применения.

Механизм снимков опирается на сериализацию и сравнение файлов

Идея проста до скучности. Компонент рендерят в тестовом окружении, результат сериализуют и сохраняют в файл, а все следующие прогоны сравнивают новый вывод с сохранённым. Совпало, тест зелёный. Разошлось, тест красный, и краснота означает ровно два варианта: либо в разметку закралась ошибка, либо вывод меняли намеренно и файл нужно перезаписать. Актуальная ветка Jest 30 делает всё это без дополнительных зависимостей, снимок кладётся рядом с тестом в каталог __snapshots__.

import { render } from "@testing-library/react";
import Link from "./Link";

it("рендерит ссылку с адресом", () => {
  const { container } = render(<Link page="https://example.com">Пример</Link>);
  expect(container.firstChild).toMatchSnapshot();
});

При первом прогоне Jest запишет файл с сериализованным деревом:

exports["рендерит ссылку с адресом 1"] =
  <a
    class="link"
    href="https://example.com"
  >
    Пример
  </a>;

Дальше механика включается сама. Флаг -u перезаписывает расшатавшиеся снимки, а режим --ci в конвейере отказывается молча создавать новые файлы и заставляет зафиксировать их в репозитории осознанно. Устаревшие записи, оставшиеся от удалённых проверок, Jest честно перечисляет в отчёте со словом obsolete, и их чистят тем же флагом -u.

Отдельная деталь, о которой стоит помнить при подъёме старого проекта на React 19: рендерер react-test-renderer, на котором десять лет держались снимочные примеры, объявлен устаревшим. Свежие примеры в документации Jest рендерят компоненты через render из библиотеки тестирования React, как в примере выше, поэтому перенос набора на новую версию фреймворка почти всегда означает переписывание обёртки рендера.

История механизма короткая и поучительная

Механизму меньше десяти лет. Внешние файлы со снимками появились в Jest 14 осенью 2016 года: команда тест-раннера искала способ защищать интерфейс без утомительных ручных проходов по экранам, и сериализация дерева показалась ей дешевле скриншотов. Приём понравился и за пределами React. В Vitest, раннере из экосистемы Vite, механика повторена почти один в один, вплоть до имени функции toMatchSnapshot, поэтому навык переносится между проектами без переучивания. В 2017-2018 годах набор расширили сопоставителями свойств и inline-записью, дальше интерфейс практически не менялся, так что наборы пятилетней давности обновляются малой кровью.

Показательна и судьба первых восторгов. На старте снимки подавали как способ покрыть интерфейс почти без усилий, и ровно этот аргумент через несколько лет приводил к заваленным шумом наборам, о которых пойдёт речь ниже. Инструмент, который обещает защиту без понимания, рано или поздно выставляет счёт за такое обещание.

Реальная польза живёт в дизайн-системе и общих компонентах

Снимок окупается там, где у компонента много потребителей и мало вариантов. Кнопка, поле ввода, карточка в дизайн-системе рендерятся одинаково в десятке мест, и любое непреднамеренное изменение их разметки разъезжается по всему приложению. Здесь красный тест почти всегда означает настоящий регресс, потому что общую кнопку просто так никто не трогает.

Отдельный бонус получают наборы, которые покрывают матрицу состояний. Кнопка в дизайн-системе существует в нескольких вариантах, и снимки фиксируют каждый:

["primary", "ghost", "danger"].forEach((variant) => {
  it("кнопка в варианте " + variant, () => {
    const { container } = render(<Button variant={variant}>Ок</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });
});

Добавился четвёртый вариант, файл со снимками пополнился, а разница в ревью займёт несколько строк. Для компонентов с матрицей пропсов это едва ли не самый дешёвый способ поймать случайную поломку одного из состояний.

Вторая грань пользы видна в ревью. Файл снимка попадает в коммит, и система контроля версий показывает разницу построчно: было class="btn btn-md", стало class="btn btn-lg". Автору не нужно пересобирать приложение и щёлкать по экранам, чтобы увидеть последствия правки, а обзорщик видит след прямо в диффе. Типовая сценка из практики: дизайнер меняет радиус скругления у карточки товара, и дифф снимка показывает ровно один файл и ровно одно свойство, никаких скрытых последствий по остальным экранам.

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

Файл снимка читают глазами и принимают в ревью

Авторы Jest прямо рекомендуют относиться к снимкам как к коду: коммитить вместе с изменениями и просматривать при ревью. Внутренняя утилита pretty-format выравнивает сериализованное дерево, чтобы человек мог его прочесть, а не протирать глаза над сплошной строкой.

У этого совета есть жёсткая оборотная сторона. Ревьюер, который видит дифф на двести строк сериализованного дерева, читает первые пять, дальше жмёт принять. Снимок из ста элементов управления не защищает ничего, он лишь создаёт видимость проверки, и в этом смысле вреднее полного отсутствия проверки.

Против раздутых файлов у Jest есть встроенное лекарство. Функция toMatchInlineSnapshot записывает ожидаемое значение прямо внутрь теста, а при наличии prettier в проекте делегирует форматирование ему:

it("сворачивает раскрывающийся список", () => {
  const { container } = render(<Details title="Состав">Специи и травы</Details>);
  expect(container.firstChild).toMatchInlineSnapshot();
});

После первого прогона аргумент появится внутри вызова сам, пять строк разметки рядом с проверкой читаются мгновенно и не теряются в общем каталоге.

Разглядывание разницы и подтверждение обновления встроены и в watch-режим: Jest проводит по провалившимся снимкам по одному, показывает обе версии и ждёт решения по каждой. Механика приличная, однако выбор в ней всё равно делает человек, и никакой диалог не заменит взгляда на разницу глазами.

Слепое размножение снимков превращает набор в источник шума

Теперь обратная сторона. Снимок натянули на страницу целиком, вместе с датой формирования и случайным идентификатором сессии. Такой снимок падает при каждом прогоне, разработчик бежит за флагом -u, зелёный цвет возвращается, и через месяц в команде формируется рефлекс: красный снимок значит обнови, а не подумай. После этого набор перестаёт ловить ошибки в принципе, он просто фиксирует шум.

Разрастается и хвост устаревших записей. В начале это выглядит безобидно:

2 obsolete snapshot files found.

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

У шума есть и внешнее проявление в истории коммитов. В здоровом репозитории записи вроде обновлены снимки появляются редко и всегда по делу. Когда такие коммиты сыплются каждую неделю, это уже не признак активной разработки, это признак набора, который съедает чужое время и требует пересмотра.

Вторая проблема серьёзнее разъехавшихся идентификаторов. Снимок проверяет, что разметка не изменилась, и больше ничего. Сломанный обработчик клика, невидимое для сериализации падение доступа к полю, кнопка без роли для скрин-ридера, снимок молчит во всех трёх случаях. Пользователь видит неработающую кнопку, а конвейер зелёный. Инструмент, который обязан стеречь интерфейс, пропускает самую частую разновидность его поломок.

Даже экосистема вокруг React это проговорила вслух. В Storybook отдельное дополнение для автоснимков историй объявлено устаревшим и больше не сопровождается, а в свежих руководствах проекта написано прямо: снимки удобны в создании, но шумны в сопровождении, когда содержат слишком много информации, и для обычных компонентов визуальные либо интерактивные проверки подходят лучше. Когда инструмент спорит сам с собой годами, стоит прислушаться.

Гигиена снимков требует мелких деревьев и детерминизма

Шум убирают не отказом от инструмента, а дисциплиной его изготовления. Рабочие правила, которые экономят недели, сводятся к короткому перечню:

  1. один снимок на маленький компонент и одно состояние, страница целиком в снимок не помещается никогда;
  2. случайные значения вроде дат и идентификаторов либо фиксируют перед рендером, либо описывают сопоставителями;
  3. снимок принимают в ревью как обычный код, дифф на сотню строк считают сигналом переписать проверку явно;
  4. флаг обновления гоняют только после того, как разницу разглядели и признали намеренной;
  5. в конвейере включают строгий режим, чтобы новые снимки не появлялись в обход репозитория;
  6. устаревшие записи чистят сразу, иначе через год никто не вспомнит, чей это след.

Для генерируемых полей у Jest есть свойство-сопоставители, которые оставляют в снимке только форму значения:

it("карточка пользователя с генерируемыми полями", () => {
  const user = {
    id: crypto.randomUUID(),
    createdAt: new Date(),
    name: "Анна",
  };
  expect(user).toMatchSnapshot({
    id: expect.any(String),
    createdAt: expect.any(Date),
  });
});

Карточка перестаёт падать от текущего времени на сервере сборки, а тип значений по-прежнему проверяется. Есть и второй путь: подменить системные часы в тесте и получить строго одинаковый вывод на любой машине. Оба приёма бьют по корню проблемы, а не по симптомам, и именно этого от снимочной гигиены и требуется.

Своим сериализатором можно приручить и экзотические типы, которые иначе рассыпаются в малочитаемую простыню:

expect.addSnapshotSerializer({
  test: (val) => val instanceof Money,
  print: (val) => "Money<" + val.amount + " " + val.currency + ">",
});

Теперь вместо внутреннего устройства класса в снимке окажется короткая строка, и дифф снова читается за секунду.

Дополняющие проверки ловят то, что снимок не видит

Полноценный набор строится слоями, и снимок занимает в нём узкую полосу. Поведение проверяют интерактивными тестами:

it("отправляет форму по клику", async () => {
  render(<SubscribeForm />);
  await userEvent.click(screen.getByRole("button", { name: "Подписаться" }));
  expect(screen.getByText("Готово")).toBeVisible();
});

Такой тест ловит всё, мимо чего снимок проходит молча: отсутствие обработчика, провал валидации, сломанную видимость. Логику с состоянием выносят в чистые функции и проверяют юнит-тестами, сквозные сценарии отдаёт прогон в настоящем браузере. Попиксельное сравнение скриншотов ловит то, что сериализация пропускает: шрифты, наложения слоёв, итоговую каскадную таблицу стилей.

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

Вывод давно проверен на десятках проектов. Снимочные тесты приносят пользу там, где вывод компонента маленький, стабильный и общий для многих потребителей: дизайн-системы, библиотеки интерфейса, критичные карточки и формы. Там они дёшевы, честны и незаменимы в ревью. Размазанные по всему приложению ради процента охвата они превращаются в постоянный источник красного шума, приучают команду обновлять без разглядывания и маскируют настоящие дыры в проверках. Граница между пользой и вредом проходит не по инструменту, а по вопросу, который стоит задать перед созданием каждого снимка: что именно он защищает и кто осилит его дифф через полгода.