Виртуальные DOM-окружения (jsdom и happy-dom) великолепно подходят для мгновенной проверки бизнес-логики и древовидных структур компонентов. Однако они принципиально не способны проверить визуальный слой интерфейса, так как в них полностью отсутствует настоящий графический рендер-движок:
getBoundingClientRect() возвращают нули или примитивные эвристики вместо физических пикселей экрана.@container и медиа-запросы цветовых схем.:hover, :active, :focus-visible и аппаратные CSS-транзишены.Паттерн Playwright Component Testing (CT) устраняет этот разрыв: компонент монтируется в реальный браузер, получая доступ к полноценному графическому стеку ОС и видеокарте.
Это позволяет автоматизировать скриншотное попиксельное тестирование (Visual Regression Testing) и гарантировать идеальный UI на всех устройствах.
В отличие от классического E2E-тестирования, где браузер загружает тяжелый бэкенд и открывает полный URL приложения (page.goto('/dashboard')), в Playwright CT локальный легковесный Vite-сервер компилирует и монтирует конкретный изолированный React-компонент в пустой тестовый HTML-хост за 20–50 мс:
[Тестовый раннер Playwright Node.js]
│
▼ (Команда mount(<Button variant="primary" />))
[Локальный Vite CT Dev-сервер]
│ ──► Собирает минимальный HTML-хост (playwright/index.html)
│ ──► Подключает Tailwind CSS v4 и глобальные токены темы
▼
[Реальный процесс браузера: Chromium / Firefox / WebKit]
│
├──► Вычисление точных координат (BoundingBox, z-index)
├──► Рендеринг аппаратными средствами GPU
├──► Проверка hover-эффектов и CSS-транзишенов
│
▼ (Сравнение буфера кадров)
[Visual Regression Engine: toHaveScreenshot()]
│
└──► Попиксельный диффинг с золотым эталоном (Golden Master)Установите пакет раннера и скачайте браузерные бинарники с помощью pnpm exec playwright install.
Сконфигурируйте playwright/index.html и playwright/index.ts, подключите стили Tailwind CSS v4 и темы.
Импортируйте test и expect, смонтируйте компонент через mount() и опишите действия и скриншоты.
Выполните pnpm exec playwright test -u для генерации снимков-эталонов в директории __snapshots__.
Запустите тесты в официальном Docker-образе Playwright для устранения шрифтовых расхождений между OS.
Почему визуальное тестирование верстки (CSS Grid, правильность z-index и переполнение Viewport) невозможно осуществить с помощью Vitest в связке с jsdom?
position: sticky и сложных SVG в Safari (WebKit) и Chrome (Blink).focus-visible: ring-2 ring-accent-cyan) при навигации клавишей Tab.Транспиляция JSX, подключение CSS-токенов Tailwind v4 и инъекция в пустой HTML-хост.
Chromium/WebKit запускается с фиксацией плотности пикселей (devicePixelRatio) и шрифтов.
Наведение курсора мыши (.hover()), ввод текста или фокусировка с клавиатуры.
Захват скриншота элемента и попиксельное сравнение с маскированием динамических зон.
Конфигурационный файл playwright-ct.config.ts определяет браузерные движки, директорию скриншотов и параметры Vite-сервера:
import { defineConfig, devices } from '@playwright/experimental-ct-react';
import path from 'path';
export default defineConfig({
testDir: './src',
testMatch: /.*\.ct\.(ts|tsx)$/,
snapshotDir: './__snapshots__',
timeout: 15 * 1000,
fullyParallel: true,
use: {
trace: 'on-first-retry',
ctPort: 3100,
ctViteConfig: {
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
],
});// Подключение глобальных стилей дизайн-системы Tailwind CSS v4
import '@/app/globals.css';
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
// Хук перед монтированием каждого компонента (установка темной темы)
beforeMount(async ({ App }) => {
document.documentElement.classList.add('dark');
return <App />;
});Метод toHaveScreenshot() захватывает экранный буфер компонента и выполняет диффинг с эталоном. Для защиты от ложных срабатываний из-за сглаживания шрифтов настраиваются параметры допуска:
await expect(component).toHaveScreenshot('button-primary-hover.png', {
maxDiffPixelRatio: 0.01, // Допустимо не более 1% изменившихся пикселей (сглаживание шрифтов)
threshold: 0.2, // Порог цветового отличия конкретного пикселя
animations: 'disabled', // Отключение CSS-анимаций для стабилизации кадра
});Тест toHaveScreenshot() локально на macOS разработчика проходит успешно, однако в GitHub Actions на Linux-агенте постоянно падает с 0.8% несовпадением пикселей вокруг текста. Что является причиной и как правильно решить проблему?
Ниже представлен компонентный тест для сложной интерактивной карточки профиля (UserProfileCard.ct.tsx), проверяющий стили Tailwind v4, адаптивную сетку и скриншотные регрессии:
import React from 'react';
import { test, expect } from '@playwright/experimental-ct-react';
import { UserProfileCard, type UserProfileData } from '../UserProfileCard';
const MOCK_USER: UserProfileData = {
id: 'usr-9812',
name: 'Константин Архитекторов',
role: 'Principal Staff Engineer',
avatarUrl: '/avatars/sample.webp',
reputation: 9840,
joinedDate: '2026-03-15T12:00:00Z',
isVerified: true,
};
test.describe('<UserProfileCard /> Visual & Interaction CT', () => {
test('соответствует визуальному эталону в десктопном представлении', async ({ mount }) => {
const component = await mount(
<div className="p-8 bg-[#0B0B11]">
<UserProfileCard user={MOCK_USER} />
</div>
);
// Маскируем аватар, чтобы избежать флаков от задержки подгрузки картинки
await expect(component).toHaveScreenshot('user-card-desktop-idle.png', {
mask: [component.locator('[data-testid="user-avatar"]')],
maxDiffPixelRatio: 0.01,
animations: 'disabled',
});
});
test('корректно отображает Tailwind hover-эффекты и активные состояния', async ({ mount }) => {
const component = await mount(
<div className="p-8 bg-[#0B0B11]">
<UserProfileCard user={MOCK_USER} />
</div>
);
const followButton = component.getByRole('button', { name: /подписаться/i });
// Эмулируем физическое наведение курсора мыши
await followButton.hover();
// Проверяем вычисленный курсор и CSS-стиль
await expect(followButton).toHaveCSS('cursor', 'pointer');
// Фиксируем скриншот кнопки под ховером
await expect(followButton).toHaveScreenshot('user-card-follow-button-hover.png');
// Кликаем по кнопке
await followButton.click();
await expect(component.getByRole('button', { name: /вы подписаны/i })).toBeVisible();
});
test('адаптируется под мобильный Viewport 375x667 без горизонтального переполнения', async ({
mount,
page,
}) => {
// Устанавливаем мобильный размер экрана
await page.setViewportSize({ width: 375, height: 667 });
const component = await mount(
<div className="p-4 bg-[#0B0B11]">
<UserProfileCard user={MOCK_USER} />
</div>
);
// Проверяем, что карточка не выходит за границы экрана
const boundingBox = await component.boundingBox();
expect(boundingBox).not.toBeNull();
if (boundingBox) {
expect(boundingBox.width).toBeLessThanOrEqual(375);
}
// Скриншот мобильной компоновки
await expect(component).toHaveScreenshot('user-card-mobile-layout.png', {
mask: [component.locator('[data-testid="user-avatar"]')],
maxDiffPixelRatio: 0.02,
});
});
});Один и тот же браузер Chromium по-разному сглаживает шрифты (Font Subpixel Antialiasing) на macOS, Windows и Linux в GitHub Actions. Запуск toHaveScreenshot() с нулевым порогом неизбежно ломает CI.
// ❌ Антипаттерн: 0% терпимости к пикселям (тест упадет на Linux в CI)
await expect(component).toHaveScreenshot('modal.png');
// ✅ Правильное решение: настроить допустимый коэффициент различия
await expect(component).toHaveScreenshot('modal.png', {
maxDiffPixelRatio: 0.02, // 2% отклонения пикселей на границах шрифтов
threshold: 0.2,
});Если компонент содержит бесконечный пульсирующий индикатор (animate-pulse) или вращающийся спиннер (animate-spin), скриншот захватывается в случайной фазе кадра, вызывая постоянные ложные падения.
// ❌ Антипаттерн: скриншот лоадера в произвольный момент фазы
await expect(component).toHaveScreenshot();
// ✅ Правильное решение: отключение анимаций через опцию animations: 'disabled'
// или маскирование анимированного узла через mask
await expect(component).toHaveScreenshot({
animations: 'disabled',
mask: [component.locator('.animate-pulse')],
});Запуск реального браузера требует времени на инициализацию процесса (30–80 мс на тест). Попытка переписать все 1 000 модульных тестов логики на Playwright CT приведет к тому, что прогон CI займет 30 минут вместо 10 секунд. Решение: Использовать Vitest для 80% проверок бизнес-логики и хуков, а Playwright CT — строго для проверки визуального слоя, сложных CSS Grid сеток и ключевых компонентов дизайн-системы.