Функціональні інтеграційні та наскрізні тести оперують деревом доступності (Accessibility Tree) та структурою DOM. Твердження на зразок expect(locator).toBeVisible() лише підтверджують, що елемент присутній у розмітці, має ненульові геометричні розміри та не прихований стилями display: none або visibility: hidden. Проте вони принципово не здатні виявити візуальні деградації інтерфейсу:
z-index та isolation: isolate);overflow: hidden;line-height) або відступів (padding, margin).Візуальне регресійне тестування (Visual Regression Testing) закриває цю прогалину шляхом попіксельного порівняння знімка поточного стану інтерфейсу з попередньо затвердженим еталонним зображенням (Golden Master).
У сучасних пайплайнах основним інструментом виступає нативний рушій скріншотного тестування Playwright, що базується на оптимізованому алгоритмі попіксельного дифінгу pixelmatch і прямому доступі до буфера кадру браузерного ядра (Blink, Gecko, WebKit).
В основі візуального тестування лежить перетворення дерева DOM та обчислених CSS-стилів у растровий буфер пікселів (Bitmap) із подальшим детермінованим порівнянням двох масивів RGBA-байтів.
+-------------------------------------------------------------------------+
| Playwright Test Engine (Node.js Runner) |
+-------------------------------------------------------------------------+
|
v CDP / BiDi
+-------------------------------------------------------------------------+
| Headless Browser (Chromium / WebKit) |
| 1. Freeze DOM & Time (page.clock.setFixedTime) |
| 2. Disable Animations & Caret (CSS: * { animation: none !important }) |
| 3. Wait for Web Fonts (document.fonts.ready) |
| 4. Mask Dynamic Elements (mask: [locator]) -> Fill Rect #FF00FF |
| 5. Capture Framebuffer (Skia / CoreGraphics Rasterizer) |
+-------------------------------------------------------------------------+
|
v Фактичний буфер (actual.png)
+-------------------------------------------------------------------------+
| Pixelmatch Diffing Engine |
| |
| Actual PNG [RGBA] <--- Compare ---> Golden Master PNG [RGBA] |
| | | |
| +-------------------+-------------------+ |
| | |
| v |
| Delta-E Color Distance per Pixel |
| Is Distance > threshold (0.2)? |
| / \ |
| [Так] [Ні] |
| | | |
| Позначити червоним (#FF0000) Залишити сірим |
| Інкремент лічильника diff Без змін |
| | |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Threshold Validation |
| |
| diffPixels / totalPixels <= maxDiffPixelRatio (e.g. 0.001)? |
| / \ |
| [Так] [Ні] |
| v v |
| PASS (Green) FAIL (diff.png збережено в артефакти)|
+-------------------------------------------------------------------------+mask: Locator[].Button, Input, Select, Modal) у всіх можливих станах (hover, focus-visible, disabled, error) та колірних темах (Light, Dark, High Contrast).playwright.config.ts#Для усунення помилкових спрацьовувань глобальна конфігурація Playwright налаштовується на максимальну стабільність рендерингу: вимикаються анімації, приховується текстовий курсор та фіксується щільність пікселів (deviceScaleFactor: 1).
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
export default defineConfig({
testDir: './e2e/visual',
snapshotDir: './e2e/__snapshots__',
// Розділення снепшотів за операційними системами та браузерними платформами
snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-{platform}{ext}',
timeout: 30_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
viewport: { width: 1280, height: 720 },
deviceScaleFactor: 1,
},
expect: {
toHaveScreenshot: {
// Чутливість до різниці кольору на рівні пікселя (0..1)
threshold: 0.2,
// Допустима частка змінених пікселів (0.1% від загальної площі)
maxDiffPixelRatio: 0.001,
// Примусове вимкнення анімацій CSS та Web Animations API
animations: 'disabled',
// Приховування миготливого текстового курсора
caret: 'hide',
// Масштабування за фізичними CSS-пікселями
scale: 'css',
},
},
projects: [
{
name: 'chromium-desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'chromium-mobile',
use: {
...devices['Pixel 7'],
},
},
],
});Інтерфейси реальних застосунків містять мінливі дані: мітки поточного часу, таймери зворотного відліку, випадкові ID сесій та аватари сторонніх CDN. Без маскування ці вузли породжують постійні помилкові падіння.
import { test, expect } from '@playwright/test';
test.describe('Візуальна верифікація панелі білінгу', () => {
test.beforeEach(async ({ page }) => {
// 1. Фіксація системного часу браузера для детермінізму дат
await page.clock.setFixedTime(new Date('2026-09-10T12:00:00.000Z'));
// 2. Мокування динамічних відповідей API балансу
await page.route('**/api/v1/billing/summary', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
balance: 14250.0,
currency: 'USD',
planName: 'Enterprise Scale',
renewalDate: '2026-10-10',
}),
});
});
await page.goto('/dashboard/billing');
// 3. Очікування повного завантаження вебшрифтів перед створенням знімка
await page.evaluate(async () => {
await document.fonts.ready;
});
});
test('скріншот усієї сторінки білінгу з маскуванням нестабільних вузлів', async ({ page }) => {
// Вибірка динамічних елементів для накладання маски
const dynamicTimestamp = page.getByTestId('server-uptime-clock');
const userAvatar = page.getByTestId('header-user-avatar');
const externalBanner = page.locator('[data-sponsored-ad]');
await expect(page).toHaveScreenshot('billing-dashboard-full.png', {
fullPage: true,
mask: [dynamicTimestamp, userAvatar, externalBanner],
maskColor: '#FF00FF', // Яскравий маджента для очевидного контролю масок у звіті
maxDiffPixelRatio: 0.0005,
});
});
test('ізольований скріншот модального вікна зміни тарифу', async ({ page }) => {
const upgradeButton = page.getByRole('button', { name: 'Змінити план' });
await upgradeButton.click();
const modalWindow = page.getByRole('dialog', { name: 'Вибір тарифного плану' });
await expect(modalWindow).toBeVisible();
// Скріншот безпосередньо локатора модального вікна замість усього viewport
await expect(modalWindow).toHaveScreenshot('modal-plan-selector.png', {
animations: 'disabled',
threshold: 0.15,
});
});
});Головна причина нестабільності візуальних тестів у розподілених командах — суттєва різниця рушіїв растеризації тексту в операційних системах:
Один і той самий гліф шрифту Inter розміром 16px на macOS та Ubuntu матиме різні контури субпікселів на межах літер. Відмінність становить 3–7% пікселів, через що тести, згенеровані на робочих станціях розробників, падають у CI на базі Linux.
[Робоча станція macOS] --> Рендеринг через CoreText --> Golden Snapshot A
[GitHub Actions Ubuntu] --> Рендеринг через FreeType --> Actual Snapshot B
|
[Конфлікт pixelmatch]
Diff: 4.8% (Fail)Виробниче рішення: стандартизація середовища в офіційному Docker-контейнері Playwright. І генерація еталонів, і їхня перевірка виконуються суворо всередині ідентичного Linux-контейнера.
# Версія образу повинна суворо відповідати версії @playwright/test у package.json
FROM mcr.microsoft.com/playwright:v1.49.0-noble
WORKDIR /app
# Встановлення системних шрифтів для передбачуваної типографіки
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation \
fonts-noto-color-emoji \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Копіювання маніфестів залежностей
COPY package.json pnpm-lock.yaml ./
# Активація pnpm через Corepack
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile
COPY . .
# Команда за замовчуванням запускає візуальний сьют
CMD ["pnpm", "exec", "playwright", "test", "e2e/visual"]services:
playwright-visual:
build:
context: .
dockerfile: Dockerfile.playwright
ipc: host # Запобігає переповненню пам'яті /dev/shm у Chromium
volumes:
- .:/app
- /app/node_modules
environment:
- CI=true
network_mode: host# Запуск візуальних тестів у стандартизованому оточенні Docker
docker compose -f docker-compose.visual.yml run --rm playwright-visual
# Оновлення еталонних знімків (Golden Master) після планового редизайну
docker compose -f docker-compose.visual.yml run --rm playwright-visual \
pnpm exec playwright test e2e/visual --update-snapshotsСтворення еталонних снапшотів локально на робочій станції macOS та їхній комміт у Git призводить до миттєвого падіння тестів у GitHub Actions (де ранери працюють на Ubuntu).
// ❌ Сгенеровано на macOS:
// e2e/__snapshots__/dashboard-chromium-darwin.png
// У Linux CI Playwright шукає:
// e2e/__snapshots__/dashboard-chromium-linux.png -> Тест падає через відсутність або дельти шрифтів
// ✅ Рішення: Усі снепшоти генеруються та оновлюються ВИКЛЮЧНО всередині Playwright Docker:
// docker compose -f docker-compose.visual.yml run --rm playwright-visual pnpm exec playwright test --update-snapshotsВиклик toHaveScreenshot() до повного переходу вебшрифтів у статус 'loaded' (FOUT/FOIT) призводить до захоплення системного шрифту з подальшим падінням тесту через випадкові затримки мережі.
// ❌ Ненадійно: сторінка відкрита, але вебшрифти ще не застосувалися
await page.goto('/catalog');
await expect(page).toHaveScreenshot('catalog.png');
// ✅ Надійно: очікування Font Loading API та стабілізації мережі
await page.goto('/catalog', { waitUntil: 'networkidle' });
await page.evaluate(async () => {
await document.fonts.ready;
});
await expect(page).toHaveScreenshot('catalog.png');Встановлення завищених лімітів розбіжності заради «придушення флакування» нівелює цінність візуального контролю: тести пропустять зникнення кнопки чи зміщення іконки оплати.
// ❌ Небезпечно: дозволяє 10% спотворення інтерфейсу без повідомлення про помилку
await expect(page).toHaveScreenshot({
maxDiffPixelRatio: 0.1,
threshold: 0.5,
});
// ✅ Коректно: суворий поріг у комбінації з точковим маскуванням
await expect(page).toHaveScreenshot({
maxDiffPixelRatio: 0.001, // Не більше 0.1% змінених пікселів
threshold: 0.2,
mask: [page.getByTestId('dynamic-live-ticker')],
});Підготовка стандартизованого Dockerfile на базі mcr.microsoft.com/playwright зі встановленими системними шрифтами.
Заморожування системного часу браузера (page.clock), мокування динамічних запитів API та блокування трекерів.
Очікування завершення document.fonts.ready, програмне вимкнення CSS-анімацій та приховування курсора вводу.
Передача локаторів годинників, динамічних аватарів та лічильників у масив mask для заливки однорідним кольором.
Порівняння захопленого буфера з Golden Master за допомогою pixelmatch. При перевищенні maxDiffPixelRatio генерація diff.png в артефакти.