Visual regression в Playwright: screenshot baseline без боли
Visual regression — это тесты, которые проверяют, что страница выглядит так же, как раньше. Не «работает» (это делают функциональные тесты), а именно «выглядит». Сценарий из жизни: разработчик правит margin кнопки на 4px, тесты зелёные, дизайнер пишет в Slack «вы сломали отступы на главной». Visual regression ловит это до прода.
В Playwright инструменты для скриншотов встроены, но из коробки набор быстро становится источником флакая. Покажу, что попробовала за пять лет работы с визуалкой, что зашло, что не зашло.
Базовый сценарий
Минимальный visual regression в Playwright выглядит так:
import { test, expect } from '@playwright/test';
test('главная страница не изменилась', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png', {
maxDiffPixels: 100,
threshold: 0.2,
});
});
Первый прогон сохраняет baseline-скриншот, последующие сравнивают с ним. Если разница больше порога — тест падает, в репорт кладётся diff-картинка.
Это работает в учебном примере. На реальном продукте через неделю набор покрасится без видимых причин: шрифт подгрузился чуть позже, анимация не доиграла, дата в шапке поменялась.
Главная боль: динамический контент
Любой динамический пиксель на странице — будущий flaky-тест. Список того, что я обязательно скрываю или фиксирую перед скриншотом:
- Текущая дата и время.
- Аватарки пользователей с randomuser.me и подобных сервисов.
- Анимации входа (fade-in элементов).
- Анимированные индикаторы загрузки.
- Видео и iframe-ы с внешним контентом.
- Reklamные блоки.
- Ошибки шрифтов до их полной загрузки.
Перед скриншотом гоняю стандартный preflight:
async function preparePageForSnapshot(page: import('@playwright/test').Page) {
await page.addStyleTag({
content: `
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
`,
});
// Дождаться полной загрузки шрифтов
await page.evaluate(() => document.fonts.ready);
// Ждём, пока все картинки догрузятся
await page.waitForFunction(() => {
const imgs = Array.from(document.images);
return imgs.every((i) => i.complete && i.naturalWidth > 0);
});
}
test('главная — снимок', async ({ page }) => {
await page.goto('/');
await preparePageForSnapshot(page);
await expect(page).toHaveScreenshot('home.png');
});
Эта функция убирает 80% «само-флакая». Дальше идут точечные маски на оставшийся динамический контент.
Маски через locator
Playwright поддерживает mask в опциях скриншота. Передаёшь массив локаторов — они закрашиваются в фиксированный цвет:
await expect(page).toHaveScreenshot('feed.png', {
mask: [
page.getByRole('time'),
page.getByRole('img', { name: /аватар/i }),
page.getByTestId('online-counter'),
],
});
Плюс — не надо менять разметку. Минус — если локатор не найдёт элемент, маска не наложится, тест упадёт по разнице. Поэтому маски пишутся на стабильные ARIA-роли и data-testid.
Где брать стабильные данные
Visual regression на продовом окружении — гарантированный кошмар. Контент меняется каждые пять минут, любой A/B покрасит набор. Базовое правило: визуалка идёт на отдельном тестовом стенде с фиксированными seed-данными.
Делаю так:
- Перед прогоном поднимаю базу данных из снапшота. Postgres-снапшот восстанавливается за секунды.
- Время приложения замораживаю через переменную окружения
APP_FIXED_TIME=2026-05-23T10:00:00Z. Бекенд читает её и возвращает фронту фиксированную дату. - Внешние сервисы замокированы через WireMock или собственный заглушечный сервис.
- Шрифты включены через локальный CDN, не через внешний.
Без этих четырёх шагов набор будет красить через раз. Я знаю, что писать «стабильный стенд для визуалки» дорого, но альтернатива — толстая папка с baseline-скриншотами, которую раз в неделю кто-то пересохраняет «потому что флакают».
Гранулярность снимков
Снимать всю страницу целиком — соблазнительно, но плохо масштабируется. Любая правка футера красит снимки всех страниц сайта. Лучше делать скриншоты компонентов:
test('кнопка primary в hover-состоянии', async ({ page }) => {
await page.goto('/styleguide');
const button = page.getByRole('button', { name: 'Primary' });
await button.hover();
await expect(button).toHaveScreenshot('button-primary-hover.png');
});
На styleguide-странице (Storybook, Ladle) живут все ключевые компоненты в фиксированных состояниях. Один компонент — один-два снимка на ключевые состояния (hover, focus, disabled, loading). Падения локализуются по компоненту, дизайнеры быстро понимают, где правка.
Кросс-браузерность
Скриншоты в Chromium и в WebKit отличаются. Анти-алиасинг шрифтов, рендер скруглений, обработка субпикселей. Если визуалку гонять в обоих — придётся хранить две папки baseline-ов и принимать падения в каждом релизе браузера.
Моё решение — основной набор только в одном браузере (Chromium), отдельный набор «smoke» в WebKit и Firefox с гораздо более либеральным maxDiffPixelRatio. Этот smoke ловит только сильные отклонения вроде «у нас всё съехало».
Хранение baseline
Baseline-скриншоты могут весить много. У меня в крупном проекте папка занимала 800 МБ. Класть в Git напрямую — оно будет тормозить. Использую Git LFS:
git lfs track "*.png"
git add .gitattributes
git add tests/__screenshots__
Альтернатива — отдельный bucket S3 и скрипт, который скачивает baseline перед прогоном. Подходит, если у команды нет привычки работать с LFS.
Что делать с осознанным изменением UI
Дизайнер сказал «новый отступ 24px вместо 20». Тесты падают. Нужно перегенерить baseline:
npx playwright test --update-snapshots
Главное — не запускать это вслепую на CI. Сделать локально, посмотреть глазами на изменения, закоммитить с понятным сообщением «design: новые отступы по PR-123». Иначе через неделю никто не помнит, какие изменения были осознанными, а какие «само сломалось».
Что не зашло
- Process на каждый релиз пересохранять все baseline. Это то же самое, что отключить тесты. Проверка перестаёт находить регрессии.
- Visual regression на продовых данных. Контент динамический, пересохранения каждый день, набор бесполезен.
- Снимки полных страниц на каждом виде viewport. Десктоп + ноут + планшет + мобила — это четыре раза больше baseline-ов и четыре раза больше времени прогона. Реальная польза — только на адаптивных компонентах.
- Сравнение через Percy/Chromatic как замена локальной визуалке. Сервисы хорошие, но добавляют внешнюю зависимость и платную подписку. На небольшой команде стоковый Playwright + LFS закрывают задачу без подписки.
Чек-лист
- Перед скриншотом отключить анимации и transitions.
- Дождаться
document.fonts.readyи загрузки картинок. - Замаскировать аватарки, время, счётчики.
- Гонять на стенде с фиксированной БД и замороженным временем.
- Делать снимки на уровне компонентов в styleguide, а не страниц целиком.
- Один основной браузер для строгих сравнений, остальные — smoke.
- Baseline в LFS или S3, не в обычном Git.
- Update snapshots — только локально и осознанно.
Visual regression — это не «бесплатное покрытие UI» из учебника. Это отдельный набор тестов с собственной инфраструктурой, собственной flaky-rate и собственными ритуалами поддержки. Сделанная без подготовки — она будет первым кандидатом на удаление через месяц. Сделанная с подготовкой — экономит часы код-ревью каждую неделю.