toHaveScreenshot() создаёт базовый скриншот при первом запуске и падает при каждом последующем если разница в пикселях превышает порог. Самый частый первый провал в CI не связан с реальной регрессией: базовые скриншоты сделанные на macOS разработчика не совпадают с тем что рендерит Linux CI, потому что хинтинг шрифтов и субпиксельный рендеринг различаются между платформами. Эта статья разбирает настройку порогов, маскировку динамического контента вроде временных меток и аватаров, генерацию Linux-совместимых базовых скриншотов через Docker-образ Playwright, и когда коммерческий инструмент вроде Applitools оправдывает затраты по сравнению со встроенным подходом.
Что такое визуальное регрессионное тестирование
Визуальный регрессионный тест делает скриншот страницы или элемента, сохраняет его как базовый, и затем сравнивает каждый следующий прогон с этим базовым пиксель за пикселем. Если разница превышает настраиваемый порог, тест падает и показывает точно какие пиксели изменились.
Важно понимать разницу со стандартным скриншотом. page.screenshot() просто сохраняет файл. Он никогда не падает. Он ничего не говорит о том правильно ли выглядит страница. Визуальное регрессионное тестирование требует эталон (согласованное изображение "так должно выглядеть") и автоматическое сравнение с ним при каждом прогоне.
Польза реальная. Ловишь визуальные регрессии которые никакой функциональный ассерт никогда не обнаружит: CSS-изменение которое сдвинуло модальное окно на пять пикселей влево, z-index баг который спрятал выпадающий список за баннером, реализация тёмной темы которая случайно инвертировала логотип. Именно такие баги проходят code review: ревьюеры смотрят на логику, не на пиксели.
Сложности тоже реальные. Скриншоты чувствительны. Однопиксельная разница в антиалиасинге между macOS и Linux, динамическая временная метка на странице, ротирующийся рекламный баннер: всё это генерирует ложные падения. Управление этим шумом и есть основная практическая работа в визуальном тестировании.
toHaveScreenshot(): встроенный ассерт
Визуальный ассерт в Playwright: expect(locator).toHaveScreenshot() или expect(page).toHaveScreenshot(). Можно делать скриншот всей страницы или ограничиться любым локатором.
// tests/visual/homepage.spec.ts
import { test, expect } from '@playwright/test';
test('homepage matches baseline', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
// Скриншот всей страницы
await expect(page).toHaveScreenshot('homepage.png');
});
test('login button matches baseline', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
// Скриншот конкретного элемента
const loginButton = page.getByRole('button', { name: 'Login' });
await expect(loginButton).toHaveScreenshot('login-button.png');
});Аргумент с именем ('homepage.png') необязателен. Если не указать, Playwright сгенерирует имя из названия теста и счётчика. Явное имя упрощает поиск базовых файлов когда потом просматриваешь их.
При первом запуске базового скриншота для сравнения нет. Playwright его создаёт.
Генерация базовых скриншотов при первом запуске
Запусти тесты первый раз и увидишь ошибки вроде:
Error: A snapshot doesn't exist at tests/visual/homepage.spec.ts-snapshots/homepage-chromium-darwin.png, writing actual.Это ожидаемо. Playwright сообщает что записал базовый файл и просит проверить и закоммитить его. Тест падает при первом запуске намеренно. Playwright не создаёт базовые скриншоты молча без твоего ведома.
После первого запуска в проекте появится директория со снимками:
tests/
visual/
homepage.spec.ts
homepage.spec.ts-snapshots/
homepage-chromium-darwin.png
homepage-chromium-linux.png
login-button-chromium-darwin.pngПросмотри эти изображения. Если выглядят правильно, закоммить их. Теперь они базовые. Каждый последующий прогон сравнивает с этими закоммиченными файлами.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// Где хранятся снимки. По умолчанию рядом со spec-файлом.
snapshotDir: './tests/__snapshots__',
use: {
baseURL: 'https://lab.becomeqa.com',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});Через snapshotDir можно собрать все снимки в одной директории: некоторые команды предпочитают такую организацию репозитория.
Обновление базовых скриншотов с --update-snapshots
Приложение меняется. Дизайн меняется. Когда визуальное изменение намеренное, нужно обновить базовые скриншоты. Запусти:
npx playwright test --update-snapshotsЭто перезапишет все существующие снимки свежими скриншотами. Каждый прогонённый тест получит текущее состояние как новый базовый.
Если нужно обновить снимки только для одного файла:
npx playwright test tests/visual/homepage.spec.ts --update-snapshotsИли для конкретного теста по имени:
npx playwright test --update-snapshots -g "homepage matches baseline"--update-snapshots так же осторожно как к git push --force. Небрежный запуск перезапишет рабочие базовые скриншоты сломанным состоянием. Всегда проверяй обновлённые изображения перед коммитом. В CI флаг никогда не должен выставляться автоматически. Только в ответ на намеренное действие разработчика.После обновления закоммить изменённые .png файлы. В diff при code review будут видны изображения "до" и "после", что именно то место где нужно ловить непреднамеренные визуальные изменения.
Настройка порогов сравнения
Попиксельное сравнение работает отлично в контролируемом окружении и генерирует постоянный шум везде остальном. Playwright даёт три опции порогов для управления чувствительностью.
test('product card matches baseline', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/products');
const productCard = page.locator('.product-card').first();
await expect(productCard).toHaveScreenshot('product-card.png', {
// Максимальное количество пикселей которым разрешено отличаться
maxDiffPixels: 100,
// Максимальная доля отличающихся пикселей (0–1). 0.01 = 1% всех пикселей
maxDiffPixelRatio: 0.01,
// Порог разницы цвета на пиксель (0–1). Выше = толерантнее
threshold: 0.2,
});
});threshold управляет тем насколько должен отличаться отдельный пиксель чтобы считаться "другим". По умолчанию 0.2: обрабатывает незначительный антиалиасинг и субпиксельные отличия рендеринга. Повысь до 0.3 или 0.4 для компонентов с кривыми или градиентами где рендеринг немного варьируется между платформами.
maxDiffPixels: абсолютное количество. Используй для небольших компонентов где знаешь что несколько пикселей могут варьироваться (рендеринг иконок, скругление углов), но сдвиг на 50 пикселей должен всегда падать.
maxDiffPixelRatio: процент от всех пикселей. Используй для скриншотов всей страницы где общее количество пикселей большое. maxDiffPixels: 100 на странице 1920x1080 это очень строго, а maxDiffPixelRatio: 0.001 даёт разумную толерантность.
В playwright.config.ts можно задать значения по умолчанию чтобы не повторять одни и те же пороги в каждом тесте:
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
threshold: 0.2,
},
},
});Отдельные тесты по-прежнему могут переопределять эти значения если нужна другая чувствительность.
Маскировка динамического контента
Динамический контент: главный источник ложных падений в визуальных тестах. Временная метка которая обновляется каждую секунду, аватар пользователя из CDN, ротирующийся рекламный баннер. Любой из них будет генерировать diff при каждом прогоне.
Опция mask принимает массив локаторов. Эти области закрашиваются сплошным цветом перед сравнением.
test('dashboard matches baseline', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/dashboard');
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
// Маскируем временную метку "Последнее обновление" в шапке
page.locator('[data-testid="last-updated-timestamp"]'),
// Маскируем аватар пользователя: у каждого он свой
page.locator('[data-testid="user-avatar"]'),
// Маскируем контейнеры со сторонней рекламой
page.locator('.ad-container'),
],
// Цвет маски (по умолчанию пурпурный оверлей)
maskColor: '#FF00FF',
});
});Замаскированные области выглядят в сравнении как сплошной блок цвета. Сравнение по-прежнему проходит по всему скриншоту. Замаскированные области просто всегда совпадают сами с собой: и в актуальном и в ожидаемом скриншоте применена одна и та же маска.
data-testid атрибуты к динамическому контенту специально чтобы их можно было надёжно маскировать в визуальных тестах. Выборка по имени класса работает, но имена классов меняются. data-testid="user-avatar" стабилен и явно сообщает своё назначение тому кто читает тест.Для анимаций используй animations: 'disabled' чтобы остановить CSS-анимации перед скриншотом:
test('animated hero section matches baseline', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await expect(page).toHaveScreenshot('hero.png', {
animations: 'disabled',
});
});Это замораживает CSS-переходы и анимации в начальном состоянии, делая анимированные компоненты детерминированными. Для анимаций на JavaScript которые не используют CSS-переходы, возможно придётся дождаться завершения анимации или добавить waitForLoadState('networkidle') перед ассертом.
Именование снимков и организация по платформам
Посмотри на имя файла снимка которое генерирует Playwright: homepage-chromium-darwin.png. В имя встроены браузер и операционная система. Это не случайно.
Одна и та же страница рендеренная в Chromium на macOS и в Chromium на Linux даёт чуть разные пиксели. Хинтинг шрифтов, субпиксельный рендеринг, небольшие различия в том как ОС компонует графику: всё это означает что нельзя использовать один базовый файл для разных платформ. Playwright решает это создавая отдельные базовые скриншоты для каждой комбинации браузер/ОС.
tests/__snapshots__/
homepage.spec.ts/
homepage-chromium-darwin.png (macOS Chrome)
homepage-chromium-linux.png (Linux Chrome)
homepage-firefox-linux.png (Linux Firefox)
homepage-webkit-darwin.png (macOS Safari)Шаблон именования настраивается через snapshotPathTemplate в playwright.config.ts:
// playwright.config.ts
export default defineConfig({
snapshotPathTemplate:
'{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-{platform}{ext}',
});Доступные токены:
{arg}: имя переданное вtoHaveScreenshot(){projectName}: имя проекта из конфига (напримерchromium,firefox){platform}: ОС (darwin,linux,win32){testFileName}: имя spec-файла без расширения{snapshotDir}: базовая директория снимков
Оставляй {platform} в шаблоне. Убрать его и пытаться использовать один базовый скриншот для разных ОС. Самая частая ошибка команд при первой настройке визуальных тестов. Результат: постоянные ложные падения в CI.
Запуск визуальных тестов в CI
Запуск в CI сразу обнажает кросс-платформенную проблему. Базовые скриншоты сгенерированы на macOS разработчика. CI-пайплайн работает на Linux. Снимки не совпадают.
Самое чистое решение: генерировать базовые скриншоты внутри того же Docker-контейнера который использует CI. Playwright предоставляет официальные Docker-образы:
# .github/workflows/visual-tests.yml
name: Visual Tests
on: [push, pull_request]
jobs:
visual:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.44.0-jammy
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run visual tests
run: npx playwright test tests/visual/
- name: Upload diff report on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-visual-report
path: playwright-report/
retention-days: 7Когда тесты падают в CI, загруженный отчёт содержит актуальный скриншот, ожидаемый базовый и diff-изображение которое подсвечивает точно какие пиксели изменились. Так отличаешь реальную визуальную регрессию от несовпадения окружений.
Чтобы генерировать Linux-совместимые базовые скриншоты с macOS-машины без перехода на Linux, запусти Docker-контейнер Playwright локально:
# Генерируем Linux-совместимые базовые скриншоты с Mac
docker run --rm \
-v "$(pwd):/work" \
-w /work \
mcr.microsoft.com/playwright:v1.44.0-jammy \
npx playwright test tests/visual/ --update-snapshotsЭто запишет новые файлы снимков *-linux.png которые будут совпадать с тем что производит CI. Закоммить их и падения из-за различий платформ исчезнут.
Распространённый паттерн CI: запускать визуальные тесты в отдельном джобе или проекте, после прохождения функциональных тестов. Визуальные тесты медленнее и их падения шумнее, поэтому выделенный шаг пайплайна не позволяет им блокировать быструю обратную связь по функциональным регрессиям:
// playwright.config.ts
export default defineConfig({
projects: [
// Функциональные тесты запускаются первыми
{
name: 'functional',
testMatch: 'tests/functional/**/*.spec.ts',
},
// Визуальные тесты запускаются после функциональных
{
name: 'visual',
testMatch: 'tests/visual/**/*.spec.ts',
dependencies: ['functional'],
},
],
});Встроенный инструмент Playwright vs Applitools и Percy
Встроенное визуальное тестирование Playwright покрывает многое. Но коммерческие инструменты вроде Applitools Eyes и Percy существуют по причинам которые стоит понять.
Главное ограничение встроенного подхода: управление снимками. Каждый базовый файл живёт в репозитории. Проект с 50 визуальными тестами, 3 браузерами и 2 платформами генерирует 300 PNG-файлов. Добавь тест-кейсов и репозиторий растёт. Просматривать визуальные изменения в pull request значит смотреть на image diff в интерфейсе GitHub: работает, но неудобно для больших изображений или тонких изменений.
Applitools и Percy решают это: облачное хранение базовых скриншотов, специализированные UI для просмотра визуальных диффов, интеллектуальное AI-сравнение которое различает изменения вёрстки и изменения контента, командные воркфлоу для принятия или отклонения визуальных изменений.
Компромисс прямой:
| | Playwright (встроенный) | Applitools / Percy |
|---|---|---|
| Стоимость | Бесплатно | Платно (есть бесплатный тариф) |
| Настройка | Минуты | Минуты + API-ключ |
| Хранение базовых скриншотов | Git-репозиторий | Облако |
| UI для просмотра диффов | HTML-отчёт Playwright | Специализированный облачный UI |
| AI-сравнение | Нет | Да (Applitools) |
| Кросс-браузерные базовые снимки | Отдельные файлы для каждого браузера/ОС | Единые с нормализацией |
| Снимки в CI | Требует совпадения Docker-образа | Обрабатывает сервис |
Для соло-проекта или небольшой команды встроенный подход правильная отправная точка. Бесплатно, быстро в настройке, хорошо справляется с типичными случаями. Docker-воркфлоу закрывает кросс-платформенную проблему раз и надолго.
Для больших команд где несколько человек должны проверять и принимать визуальные изменения, трение от управления PNG-файлами в git и просмотра диффов в GitHub становится ощутимым. Вот когда специализированный сервис начинает оправдывать стоимость. Платишь за воркфлоу проверки не меньше чем за технологию сравнения.
Applitools предлагает интеграцию с Playwright которая заменяет toHaveScreenshot() вызовами eyes.check(). Переход: обновить один импорт и поменять вызов ассерта, а не переписывать тесты.
Частые вопросы
Как запустить только визуальные тесты без прогона всего сьюта?
Используй флаг --grep или разложи визуальные тесты в отдельную директорию и укажи Playwright на неё: npx playwright test tests/visual/. Если в конфиге используются проекты, npx playwright test --project=visual запустит только визуальный проект.
Снимки постоянно падают из-за спиннера загрузки который иногда появляется. Что делать?
Жди пока спиннер исчезнет перед ассертом: await page.locator('[data-testid="loading-spinner"]').waitFor({ state: 'hidden' }). Или замаскируй его. Маскировка надёжнее. Если тайминг изменится, маска по-прежнему справится, а waitFor с жёстким таймаутом может не успеть.
Можно ли использовать toHaveScreenshot() для тестирования мобильного вьюпорта?
Да. Задай вьюпорт в конфиге проекта или в самом тесте: await page.setViewportSize({ width: 375, height: 812 }). Playwright будет рассматривать мобильные и десктопные скриншоты как отдельные базовые если они снимаются в отдельных тестах или проектах.
Сколько визуальных тестов писать?
Меньше чем кажется. Визуальные тесты лучше всего подходят для компонентов и страниц где визуальный результат реально является частью спецификации: состояния кнопок в дизайн-системе, визуализация данных, предпросмотр PDF-экспорта. Попытка покрыть визуально каждую страницу создаёт бремя обслуживания от которого команды обычно отказываются через несколько месяцев.
Можно ли тестировать компонент изолированно без перехода на страницу?
Напрямую через Playwright нет. Это браузерный инструмент который работает с полными страницами. Для изолированного визуального тестирования на уровне компонентов лучше подходит Storybook с Chromatic (Percy для Storybook). Визуальные тесты Playwright работают лучше всего на уровне интеграции: реальные страницы в реальном браузере.
→ See also: Кастомные фикстуры в Playwright: паттерн, который делает тесты читаемыми | Файл конфигурации Playwright: все опции, которые нужно знать | Отладка нестабильных тестов: практическое руководство | AI визуальное регрессионное тестирование: за пределами попиксельного сравнения | Кросс-браузерное тестирование с Playwright: Chrome, Firefox, Safari