Снимок (snapshot) - это тест, который сравнивает результат не с заранее написанным ожиданием, а с сохранённым эталоном. Идея сильная: описывать большой вывод целиком вручную утомительно, а snapshot фиксирует его автоматически и потом стережёт от изменений. Но у той же силы есть тёмная сторона: снимок легко превращается в свалку, которую никто не читает, и тогда он проверяет не контракт, а сам факт того, что ничего не поменялось.
Граница проходит по размеру и осмысленности эталона. Снимок всего DOM-дерева компонента на сотни строк бесполезен: при первом же изменении вёрстки он покраснеет, разработчик махнёт рукой и обновит его не глядя. Малый, сфокусированный снимок - публичного DTO, результата форматтера, короткого куска разметки - наоборот, читается целиком, и его diff при ревью говорит по существу.
Для таких малых эталонов удобен inline-снимок. toMatchInlineSnapshot записывает ожидаемое прямо в тело теста, рядом с вызовом, а не в отдельный .snap-файл, который со временем расползается и живёт своей жизнью в стороне от кода. Эталон виден в том же месте, где живёт проверка: не нужно открывать сторонний файл, чтобы понять, что именно утверждается, и diff в pull request показывает изменение контракта прямо в коде теста, где его и прочитают на ревью.
Нестабильные поля - отдельная забота. id, дата создания, случайный токен меняются от прогона к прогону и без обработки сделают снимок вечно красным. Для этого есть property matchers: в снимке такое поле описывают не значением, а типом - id как expect.any(String), createdAt как expect.any(Date). Тогда эталон фиксирует форму и стабильную часть, не цепляясь за то, что меняется по определению.
Ключевое - что означает флаг -u. vitest -u не чинит тест и не подтверждает правильность вывода: он просто принимает текущий результат как новый эталон, каким бы тот ни был. Если запускать -u рефлекторно на каждый красный снимок, проверка исчезает: снимок начинает соглашаться с любым выводом, включая сломанный, и перестаёт что-либо защищать.
Поэтому обновление снимка - это осознанный review, а не автоматизм. Красный снимок задаёт вопрос: это я намеренно поменял контракт или это регрессия? На него отвечают, глядя в diff, а не переписывая эталон вслепую. Принят diff - значит, разработчик прочитал изменение и подтвердил, что новый вывод правильный; в этом вся ценность механизма.
Отсюда практика, которая держит снимки полезными. Снимать маленькое и осмысленное, а не всё подряд; предпочитать inline, чтобы эталон был на виду; закрывать нестабильные поля матчерами; читать каждый diff при обновлении. Снимок - это зафиксированный контракт вывода, и ценен он ровно настолько, насколько внимательно его перечитывают, когда он краснеет.
Типичный провал - огромный снимок всего дерева, который обновляют не глядя: он создаёт иллюзию покрытия, но не ловит ни одной настоящей регрессии, потому что любое изменение принимают автоматически. Второй провал - снимок с живой датой или id без property matchers: он падает на пустом месте, приучает команду жать -u рефлекторно и тем самым обесценивает все остальные снимки в проекте.
test('toPublicDto скрывает служебные поля', () => {
const dto = toPublicDto(user)
expect(dto).toMatchInlineSnapshot(
{ id: expect.any(String) }, // нестабильное поле - по типу
`
{
"email": "a@shop.io",
"id": Any<String>,
"role": "customer",
}
`,
)
})