Функциональный тест проверяет, что кнопка работает, но не замечает, что она съехала, стала невидимой на тёмной теме или потеряла отступы после правки CSS. Визуальную регрессию ловит скриншотное сравнение: Playwright снимает элемент или страницу и сверяет с сохранённым эталоном - golden-файлом. По сути это контракт рендеринга: "вот как это должно выглядеть", зафиксированный в картинке. Ломается вёрстка - скриншот расходится с эталоном, и тест краснеет.
Работает это через матчер toHaveScreenshot. await expect(page).toHaveScreenshot(...) сравнивает снимок с эталоном; при первом запуске эталон создаётся, при последующих - сверяется. Снимать можно как всю страницу, так и отдельный элемент через локатор - expect(locator).toHaveScreenshot(...) фиксирует один компонент, что устойчивее и осмысленнее, чем полностраничный снимок, зависящий от всего сразу.
У матчера есть опции, без которых визуальные тесты нестабильны. animations: 'disabled' останавливает CSS-анимации, чтобы снимок не попадал на промежуточный кадр. fullPage: true снимает всю прокручиваемую страницу, а не только вьюпорт. mask принимает локаторы областей, которые надо скрыть перед сравнением, - там, где контент по определению меняется от прогона к прогону. Эти опции превращают капризный снимок в воспроизводимый контракт.
Полезно один раз увидеть оба режима - страницу и компонент - вместе с опциями стабилизации. Ниже - полностраничный снимок оформления заказа с отключёнными анимациями и маской динамического баланса, и снимок отдельной карточки цены. К этой форме возвращаются, добавляя визуальную проверку: сначала решают, что именно снимать - страницу целиком или конкретный компонент, - и что на нём заведомо динамично.
Главная сложность визуальных тестов - зависимость эталона от окружения. Golden-файл привязан к ОС, браузеру, набору шрифтов и рендерингу: снимок с macOS разработчика не совпадёт с тем же снимком на Linux в CI из-за разного сглаживания шрифтов. Поэтому эталоны генерируют и сравнивают в одинаковом окружении - как правило, в том же Docker-контейнере, что и CI, а не на локальной машине.
Динамику маскируют точечно, а не порогом. Дата, баланс, случайный идентификатор, рекламный баннер - всё, что меняется само по себе, - закрывают маской, чтобы оно не роняло сравнение. Соблазн вместо этого поднять допустимый порог pixel diff опасен: большой порог глушит не только шум, но и настоящие регрессии - съехавший на несколько пикселей блок пройдёт незамеченным. Маскируют конкретное динамическое, а порог держат строгим.
Обновление эталонов - осознанное действие, а не автоматизм. Когда дизайн намеренно изменился, эталоны пересоздают флагом --update-snapshots, но именно после того, как убедились, что новое отображение правильное. Массово обновлять эталоны на каждый красный снимок - значит превратить визуальный тест в фикцию, которая соглашается с любым видом, включая сломанный. Красный визуальный тест задаёт вопрос: это я поменял дизайн или это регрессия?
Типичные провалы визуальных тестов предсказуемы. Эталоны, снятые локально и сравниваемые в CI, - вечные расхождения из-за разных шрифтов и рендеринга. Огромный порог pixel diff вместо маски - скрытые регрессии под видом "допустимого шума". И рефлекторное обновление всех эталонов на каждый красный снимок - визуальный тест, не проверяющий ничего. Генерируйте эталоны в окружении CI, маскируйте конкретную динамику и обновляйте снимки осознанно.
// Страница целиком: анимации выключены, динамика замаскирована
await expect(page).toHaveScreenshot('checkout.png', {
fullPage: true,
animations: 'disabled',
mask: [page.getByTestId('dynamic-balance')],
})
// Отдельный компонент - устойчивее полностраничного снимка
await expect(page.getByTestId('price-card'))
.toHaveScreenshot('price-card.png')