Playwright: игра в скриншотные тесты
Работа с Playwright всегда доставляет мне удовольствие. »Наиграв» уже десятки, если не сотни часов в библиотеке, решая проблемы, копаясь в исходниках или на просторах сети, я практически всегда находил удачное решение. И это я списываю не столько на свой опыт, сколько на удобство самой библиотеки. Чаще всего удачные решения находились уже в коробке. А то, что приходилось допиливать руками, всегда сопровождалось ощущением игры и веселья — настолько приятно и легко работать с этим инструментом. Вот уже год, как наши тесты работают стабильно без каких-либо значимых изменений. Сегодня я расскажу вам о скриншотных тестах, реализованных на основе Playwright и Storybook.
Рад приветствовать вас в блоге Okko. Меня зовут Анатолий Ивашов, я разработчик из core-команды, которая развивает web-проекты нашей компании.

Изначально я хотел сделать короткий обзор, как мы используем Playwright в скриншотных и E2E-тестах. Но потом понял, что из-за поверхностных данных это будет бесполезная статья. Поэтому решил всё-таки дать детальный разбор того, как мы внедрили скриншотные тесты у себя в проекте. Если вы интересуетесь такими тестами, но не знаете, как к ним подступиться, то после прочтения статьи у вас будет практически готовый гайд по их внедрению.
Скриншотные тесты
Главное, что нам нужно от тестирования — проверять пулл-реквесты до их слияния с основной веткой. Поэтому оба вида тестирования мы запускаем в CI/CD, помимо возможности локального запуска.
На каждый пуш в пулл-реквест у нас запускается Jenkins-таска, которая выполняет последовательно несколько действий:
Собирает сторибук из ветки пулл-реквеста.
Загружает билд сторибука на удалённый сервер, после чего он доступен по ссылке.
Добавляет в описание пулл-реквеста эту ссылку.
Запускает скриншотный тест на основе этого билда.
По завершении загружает отчёт о прохождении теста на удаленный сервер.
Добавляет в описание пулл-реквеста ссылку на отчёт.
Если таска на каком-то этапе завершается ошибкой, это блокирует слияние пулл-реквеста до тех пор, пока последующий пуш и запуск таски не завершится успехом.

У нас в команде 2 основных проекта. Web — это проект сайта okko.tv, и SmartTV — проект для телевизоров. На SmartTV, помимо Android, может стоять собственная оболочка от производителя. Например, у LG — WebOS, у Samsung — Tizen. Они используют браузер под капотом, поэтому этим проектом у нас занимается та же команда, что и разрабатывает сайт. А проектами Android и iOS занимаются отдельные команды.
Оба проекта написаны на React. Конечно, у нас есть Storybook. И на его основе мы создали скриншотные тесты.
На данный момент у нас суммарно на Web и SmartTV запускается 730 скриншотных тестов. На Web выполнение 466 тестов в CI/CD занимает меньше минуты — это именно работа Playwright. Время сборки самого сторибука, которая предшествует запуску тестов, здесь не учитывается.

Такая скорость достигается благодаря многопоточности Playwright. Он по умолчанию берет половину логических ядер машины, на которой запускается, но это настраивается. В нашем случае тут 16 потоков.
Создание теста
Итак, у нас имеется Storybook. Наш тест состоит в том, чтобы открыть каждую сторю (историю, story) на отдельной странице и сделать с неё скриншот, сравнив его с эталоном. Каждый тест — это отдельная сторя, открываемая изолированно, без вспомогательных меню Storybook. Это работает из коробки через iframe. Поэтому Playwright загружает сторю быстро, и в нашем случае делает скриншот страницы.
Создадим папку storybook-test
для скриншотных тестов. Сам код теста достаточно простой:
// storybook.test.ts
test.describe('Storybook', () => {
const { storybookUrl } = getSetupConfig()
const stories = getStories()
stories.forEach(story => {
const testName = `${story.title} / ${story.name}`
test(testName, async ({ page }) => {
const targetURL = `${storybookUrl}/iframe.html?id=${story.id}`
// Ускорение загрузки шрифтов и картинок
await useCachedFonts(page)
await useStubImages(page)
await page.goto(targetURL)
// Отключение анимаций (дополнительное)
await disableAnimations(page)
await expect(page).toHaveScreenshot(`${story.id}.png`, {
animations: 'disabled',
})
})
})
})
Здесь и далее я даю фрагменты кода, взятые из реального проекта с незначительными правками. Если я допустил где-то ошибку, можете указать на это, постараюсь исправить.
Конечно, за скромными вызовами функций скрывается та работа, на которую было потрачено много весёлого времени :)
Про сравнение скриншотов
Сравнение скриншотов выполняется вызовом метода toHaveScreenshot. При первом запуске теста, когда файла эталона ещё нет на диске, Playwright создает его. Последующие запуски теста осуществляют сравнение с этим файлом.
Эталон — это скриншот, который сохранён в репозитории и содержит «правильное» отображение стори. Если скриншот, сделанный во время теста, отличается от эталона, тест считается не пройденным, и в отчёте появится diff-изображение, которое подсветит отличия от эталона:

Когда вы намеренно вносите в компонент правки, меняющие его внешний вид, то вы заменяете имеющийся файл эталона новым скриншотом и сохраняете его в репозитории. Как это делается, покажу ниже.
Про стабильность скриншотов
На мой взгляд, скриншотные тесты в Playwright отличаются очень высокой стабильностью.
И это не пустые слова. На странице могут происходить анимации, другие движения, подёргивания. При создании эталона Playwright делает серию скриншотов до тех пор, пока два последовательных снимка не совпадут, после чего сохраняет последний на диск. Метод toHaveScreenshot
относится к так называемым auto-retrying-assertions, поэтому при последующих запусках Playwright будет сравнивать полученный скриншот с эталоном до тех пор, пока они не совпадут (в рамках заданного таймаута).
Тем не менее, в реальных проектах иногда проявляются моменты, которые объективно могут нарушать стабильность тестов, с чем Playwright не в силах помочь из коробки. Расскажу, с чем столкнулись мы.
page.route
Пожалуй, page.route — это важнейший инструмент, без которого не получится сделать более или менее продвинутое тестирование. Мы его используем для мокирования изображений, для ускорения загрузки файлов, для блокировки лишних запросов, а в E2E-тестах — для мокирования запросов к API (но рассказ про E2E-тесты не входит в данную статью). Поэтому я очень советую ознакомится с его возможностями.
Стабильность загрузки шрифтов
Наверное, это редкий опыт, но он у нас был :) Какое-то время мы ловили падения скриншотов из-за отличий в рендере текстов. Оказалось, что в проекте применяется свойство font-display: fallback. Из-за него Playwright мог не дождаться загрузки основных шрифтов и использовал другие шрифты. Сейчас мы понимаем, что есть разные способы решения этой проблемы. Но на тот момент мы пришли к решению, которое попутно ещё и ускорило наши тесты. Мы стали предзагружать в начале теста и кешировать все используемые шрифты (более 30 штук), а во время теста использовать возможности page.route
для перехвата сетевых запросов:
const cacheMap = JSON.parse('fonts.json')
const regExp = /\.(otf|woff2?|ttf|eot)$/
export const useCachedFonts = async (page: Page) => {
await page.route(regExp, async route => {
const url = route.request().url()
if (cacheMap[url]) {
await route.fulfill({ path: cacheMap[url] })
} else {
await route.continue()
}
})
}
Откуда взялся файл fonts.json
, расскажу чуть ниже.
Стабильность загрузки картинок
Большинство наших сторей используют картинки. Они загружаются с удалённого сервера изображений. Хотя этот сервер и расположен во внутреннем контуре и работает шустро, всё же это дополнительная нагрузка на сеть во время тестирования. А когда этих тестов сотни, это может стать ощутимо. И иногда, крайне редко, но всё же Playwright давал сбой при загрузке картинок. Наверняка, это косяки прошлых версий Playwright и сейчас такой проблемы нет, либо не справлялся наш сервер изображений. Тем не менее, нас это подтолкнуло к созданию ещё одного улучшения. Мы стали перехватывать запросы за картинками, подменяя их стабами (заглушками, stub), тем самым значительно снизив нагрузку на сеть и повысив стабильность и скорость тестов. Выглядит это так:

На первый взгляд выглядит странно, да? :) Но мы уже привыкли, и для нас тут нет никаких проблем. Решение сделано с помощью того же page.route
и библиотеки sharp
, которую мы уже использовали в проекте, но есть и другие аналоги.

// Заглушка в сыром виде
const PNG_STUB_FILE = fs.readFileSync('stub.png')
// Заглушка в обёртке sharp для последующей модификации
const pngStub = sharp(PNG_STUB_FILE)
export const useStubImages = async (page: Page) => {
const regExp = /(\.(bmp|gif|jpe?g|a?png|webp)$)|/images//
await page.route(regExp, async (route, request) => {
// Удостоверяемся, что браузер ждёт от нас картинку
const accept = await request.headerValue('accept')
const acceptsPng = accept?.includes('image/*')
if (!acceptsPng) {
return route.continue()
}
// В url могут быть заданы параметры изображения
const urlParams = new URL(request.url()).searchParams
const width = Number(urlParams.get('width')) || null
const height = Number(urlParams.get('height')) || null
return route.fulfill({
contentType: 'image/png',
body:
width || height
// Клиент запросил особый размер – применяем sharp
? await pngStub.resize(width, height).toBuffer()
// Клиенту не важен размер – отдаем сырой файл
: PNG_STUB_FILE,
})
})
}
В реальном проекте мы также используем webp-заглушку.
Отключение анимаций
У Playwright есть встроенный механизм отключения анимаций во время снятия скриншота — через параметр animations метода toHaveScreenshot
. В нашем случае нам этого не хватило (возможно, опять же, это проблемы прошлых версий Playwright), и мы встроили дополнительные стили на страницу через addStyleTag для железного прибивания анимаций:
async function disableAnimations(page: Page) {
await page.addStyleTag({
content: `
*, *:before, *:after {
animation-delay: 0ms !important;
animation-duration: 0ms !important;
animation-timing-function: step-start !important;
transition-duration: 1ms !important;
transition-timing-function: step-start !important;
scroll-behavior: auto !important;
}`,
})
}
Обработка ненужных http-соединений
Если Storybook запущен в watch-режиме, он держит постоянное соединение с вебпаком. Возможно, это уже не актуально, но когда мы внедряли тесты, это было так. Чтобы дождаться загрузки страницы во время page.goto
(по умолчанию ждёт события load
, но есть варианты), мы обрываем это соединение:
export const preventHmrConnection = async (page: Page) => {
await page.route('**/__webpack_hmr', route => route.abort())
}
Аналогичным образом вы можете прерывать запросы к любым своим или сторонним сервисам, вроде Яндекс-метрики, или сразу отдавать статус 200, используя route.fulfill.
Подготовка к запуску тестов
Начальная установка
Перед запуском всех тестов нам нужно выполнить несколько действий:
Составить список сторей.
Скачать шрифты (не всем это надо, нам надо).
Это подготавливается до запуска всех тестов, и в Playwright для такого случая есть параметр глобальной установки globalSetup. В нём указывается путь до файла с функцией, в которой будет проделана вся работа по подготовке. Функция может быть асинхронной, поэтому в ней можно делать, например, http-запросы.
// global-setup.ts
export default async () => {
await prepareStoriesJson()
await prepareFonts()
}
Может возникнуть вопрос: почему не использовать для этого beforeAll? Да, этот хук запускается перед тестами, но нужно учитывать, что это работает в рамках одного воркера (потока). Тесты распределены пачками между воркерами. Сколько будет запущено воркеров, столько раз будет выполнен хук beforeAll
. А функция globalSetup
будет запущена один раз, независимо от настроек многопоточности.
Подготовка списка сторей
Скриншотные тесты генерируются динамически с помощью цикла по всем сторям, поэтому нам нужно иметь их список. При сборке сторибука вы можете использовать флаг buildStoriesJson, чтобы создать файл stories.json
(index.json
для версий 7 и выше):
// .storybook/main.js
module.exports = {
core: {
builder: 'webpack5',
},
features: {
storyStoreV7: true,
buildStoriesJson: true,
},
...
}
В нём окажется список всех сторей:
// stories.json
{
"v": 3,
"stories": {
"ui-kit-select--default": {
"id": "ui-kit-select--default",
"title": "UI-KIT/Select",
"name": "Default",
"importPath": "./ui-kit/select/select.story.tsx"
},
"ui-kit-select--large": {
"id": "ui-kit-select--large",
"title": "UI-KIT/Select",
"name": "Large",
"importPath": "./ui-kit/select/select.story.tsx"
},
...
}
}
export interface StoriesJson {
v: number
stories: {
[key: string]: StoryInfo
}
}
export interface StoryInfo {
id: string
title: string
name: string
importPath: string
}
Также можно использовать CLI-команду extract. Судя по доке, она работает на Puppeteer, мы этим способом не пользовались.
Подготовка шрифтов
Мы используем плагин webpack-assets-manifest, который создаёт json-файл с перечислением всех ассетов собранного сторибука. У нас он выглядит примерно так:
// assets-manifest.json
{
"OKKO_Sans-Regular.ttf": "06889eb4fc8f9c0d29c5.ttf",
"OKKO_Sans-Regular.woff": "50d099b4a9ec0c83e993.woff",
"8f7ad1de.iframe.bundle.js": "8f7ad1de.iframe.bundle.js",
"2096e1a25e4af55ce43e.css": "2096e1a25e4af55ce43e.css",
"Fire.png": "bf197fa6eb327b371014.png",
...
}
Без труда собираем файлы шрифтов и перекладываем их в отдельный файл fonts.json
, который использовался в useCachedFonts
.
Параметры запуска теста
При локальном запуске тестов мы часто хотим управлять параметрами этого запуска. Например, протестировать только одну сторю и обновить её эталон. Так как наши тесты генерируются динамически, мы не можем воткнуть в коде test.only
явным образом. Нужно передать параметры внутрь кода генерации теста. В своём проекте мы используем команду, созданную через yargs, но для начала можно обойтись обычными переменными окружения.
Возьмём две переменные: PW_FILTER
(string) для фильтрации сторей и PW_ACCEPT
(boolean) для принятия полученных скриншотов в качестве эталонов. Рассмотрим ниже их применение.
Фильтрация сторей
Указывая переменную PW_FILTER
, мы говорим тесту запустить только некоторые стори. Фильтровать тесты будем одновременно по всем характеристикам стори — по id
, title
и по имени файла стори:
export const filterStories = (stories: StoryInfo[]): StoryInfo[] => {
const filter = process.env.PW_FILTER?.toLowerCase()
if (!filter) {
return stories
}
return stories.filter(story => {
return (
// Фильтр по имени файла стори
story.importPath.toLowerCase().includes(filter) ||
// Фильтр по названию
getTestName(story).toLowerCase().includes(filter) ||
// Фильтр по id стори
story.id === filter
)
})
}
export const getTestName = (story: StoryInfo) => {
return `${story.title} / ${story.name}`
}
Обновление эталонов
Обновление эталонов — важная часть скриншотных тестов.
Допустим, разработчик поменял компонент или сторю, он знает, что внешний вид должен поменяться. Если запустить тест этой стори, он упадёт, так как полученный скриншот будет отличаться от эталона. Как обновить эталон? Используем нашу переменную PW_ACCEPT
совместно с флагом updateSnapshots:
updateSnapshots: process.env.CI
? 'none'
: process.env.PW_ACCEPT ? 'all' : 'missing'
Таким образом, при локальном запуске:
если полученный скриншот отличается от эталона — тест упадёт;
если эталона ещё нет — он будет создан;
если указан флаг
PW_ACCEPT
— эталон будет обновлён (начиная с версии 1.50 вместоall
можно использоватьchanged
).
В CI мы ничего не создаём и не обновляем — тест должен упасть в случае отсутствия эталона или его отличия от полученного скриншота.
Конфиг Playwright
Советую обратиться к документации, есть очень много параметров, которые можно гибко настроить под ваши нужды. Приведу только часть нашего конфига:
// playwright.config.ts
export default defineConfig({
globalSetup: __dirname + '/global-setup.ts',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
snapshotPathTemplate:
__dirname + '/__screenshots__/{arg}{-projectName}{ext}',
updateSnapshots: process.env.CI
? 'none'
: process.env.PW_ACCEPT ? 'all' : 'missing',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
reporter: [
[
'html', {
outputFolder: 'html-report',
open: process.env.CI || process.env.IN_DOCKER ? 'never' : 'on-failure',
},
],
[__dirname + '/test-reporter.ts'],
],
})
Пояснения:
fullyParallel
— параллельный запуск тестов внутри одного файла. По умолчанию файлы выполняются параллельно, а тесты внутри одного файла — по порядку.retries
— в CI делаем несколько попыток, если были неудачные запуски, а локально — только одну попытку.snapshotPathTemplate
— нам удобно хранить все скриншоты в одной папке.trace: 'on-first-retry'
— сбор трейса замедляет тест и утяжеляет папку отчёта, поэтому делаем его только при перезапусках.Что такое трейсТрейс (трассировка) — это потрясающий инструмент для анализа загрузки и работы страницы, его использование особенно актуально в E2E-тестах.
screenshot: 'only-on-failure'
— делает скриншот всей страницы после неудачного запуска.Результат теста сохраняется в виде html-отчёта. При локальной разработке он автоматически открывается после неудачного запуска.
Используем дополнительный кастомный репортер
test-reporter.ts
, в котором загружаем html-отчёт в облако. Также в нём взаимодействуем с Bitbucket REST API, чтобы прикрепить ссылку на отчёт к пулл-реквесту и показать статус результата (видели зелёные галочки на скрине в начале статьи?).
Playwright в очередной раз поражает своей гибкостью.
Запуск тестов
Добавим в package.json
скрипт для запуска тестов:
{
"scripts": {
"test-storybook": "playwright test -c storybook-test/playwright.config.ts"
}
}
Собирая воедино все вышеописанные техники, получаем следующие варианты запуска тестов:
# Запуск тестов всего локального сторибука в режиме проверки
npm run test-storybook
# Запуск некоторых тестов
PW_FILTER=button npm run test-storybook
PW_FILTER=select.story.tsx npm run test-storybook
# Запуск некоторых тестов в режиме обновления скриншотов
PW_FILTER=button PW_ACCEPT=true npm run test-storybook
Как это выглядит при использовании команды, написанной через yargs:
# Тестирование сторей из папки ui-kit
npm run test-storybook -- --filter=ui-kit
# Обновление скриншотов для сторей конкретного файла
npm run test-storybook -- --filter=select.story.tsx --accept
А теперь важный момент!
Про Docker
Создавая и изменяя компонент или сторю, разработчик может прогнать тест на своей локальной машине, сгенерировать новый эталон или изменить существующий и закоммитить его в репозиторий.
Есть одно важное требование, при котором скриншотное тестирование осуществимо на стыке локальной разработки и CI/CD, это — унифицированное аппаратно-программное обеспечение. А именно процессор, видеокарта, версия браузера и т.п. Это важно, так как скриншоты, снятые на разных платформах, в разных версиях браузера или с разных видеокарт, практически всегда дают отличающиеся результаты. Например, в отступах и шрифтах. Мы хотим, чтобы скриншоты, полученные разработчиками на их компьютерах, в точности совпадали со скриншотами, полученными в CI/CD.
Даже если у вас нет CI/CD, я уверен, что все члены команды используют неодинаковые компьютеры, и скриншот, снятый одним разработчиком, будет отличаться от скриншота другого разработчика.
Для решения этой проблемы применяются docker-образы. Хорошая новость в том, что Playwright сам предоставляет эти образы. Для каждой версии библиотеки они выпускают связанный с ней docker-образ, в котором уже установлены нужные версии браузеров. Мы берём этот образ и на нём запускаем тесты — локально и в CI/CD.
Кстати, в Playwright есть экспериментальная поддержка Selenium Grid, но мы этим не пользовались.
Поэтому у нас появляется еще один скрипт — test-storybook-docker
:
{
"scripts": {
"test-storybook": "playwright test -c storybook-test/playwright.config.ts",
"test-storybook-docker": "sh test-storybook-docker.sh"
}
}
Вынесем его код в отдельный файл, чтобы было легче читать:
args=(
# Контейнер самоуничтожается после выполнения теста
--rm
# Поддержка обращения к локально запущенному сторибуку
--ipc=host --add-host=host.docker.internal:host-gateway
# Привязка папки проекта к контейнеру,
# чтобы Playwright использовал локальный код
-v=$(pwd):/target
-w=/target
# Пробрасываем переменные внутрь контейнера
-e IN_DOCKER=1
-e PW_FILTER=$PW_FILTER
-e PW_ACCEPT=$PW_ACCEPT
-e STORYBOOK_URL=$STORYBOOK_URL
# Указывается docker-образ для запуска тестов
--platform=linux/amd64
registry.playteam.ru/playwright:v1.49.1-noble
# Внутри контейнера выполняем скрипт запуска тестов
bash -c 'npm run test-storybook'
)
# Запускаем тест в контейнере, открываем отчёт при ошибках
docker run "${args[@]}" || playwright show-report storybook-test/html-report
Обратите внимание на последнюю строчку, как теперь открывается отчёт. Playwright сохраняет отчёт в папку html-report
(согласно нашему конфигу) и открывает её автоматически при неудачном запуске. Он это делает через запуск локального сервера (порт 9323, настраивается). Параметром IN_DOCKER
мы в конфиге отключили это поведение, так как не хотим запускать сервер внутри контейнера. Вместо этого мы смотрим на статус завершения и сами запускаем отчёт, уже не в контейнере. Возможно, существует более элегантное решение, стоит присмотреться к настройкам html-репортера в документации.
Я также предлагаю добавить переменную STORYBOOK_URL
. Это позволит запускать тесты с локального и удалённого сторибука:
export const getSetupConfig = () => {
return {
storybookUrl: process.env.STORYBOOK_URL || 'http://localhost:6006',
manifestName: 'assets-manifest.json',
}
}
Теперь именно команду test-storybook-docker
мы будем запускать локально:
# Локальный запуск всех тестов в режиме проверки
npm run test-storybook-docker
# Запуск тестов с удаленного сторибука
STORYBOOK_URL= npm run test-storybook-docker
# Локальный запуск с фильтрацией и обновлением скриншотов
PW_FILTER=button PW_ACCEPT=true npm run test-storybook-docker
Отчёты в Playwright
Вы можете использовать несколько отчётов (репортеров) одновременно. Из коробки Playwright предоставляет готовый набор отчётов. Среди них есть JSON-отчёт и интеграция с GitHub Actions. Мы используем html-репортер, он достаточно удобный и функциональный и вполне подошёл нам для скриншотных тестов.

Если у вас есть особые требования к интерфейсу отчёта, вы можете написать свой собственный репортер. Playwright предоставляет для этого удобное API. Или воспользуйтесь каким-нибудь сторонним решением, например, Allure Report.
Мы в будущем планируем модернизировать html-отчёт и добавить в него кнопку «Accept» прямо возле скриншота, чтобы обновлять эталоны через визуальный интерфейс, а не через терминал.
Вот вроде бы и всё.
Заключение
Как я писал в начале статьи, получилось пошаговое руководство по внедрению скриншотных тестов на основе Playwright и Storybook. Какие-то моменты я всё же опустил, но основное постарался осветить.
В конце многие дают ссылку на свой личный канал. Но я вместо этого дам ссылку на полуофициальный чат поддержки Playwright — https://t.me/playwright, где можно общаться на русском языке, в частности, с разработчиками библиотеки. Для тех, кто не в курсе, Playwright разрабатывается в Microsoft, а команда в основном состоит из русскоговорящих ребят.
Всем желаю добра и процветания, а также побольше фана на профессиональном поприще.