Визуальные регрессионные тесты проходят у вас на ноутбуке. Вы делаете push, запускается конвейер — и те же самые тесты либо молча роняют браузер, либо помечают полсотни «изменений», которые на деле лишь различия в сглаживании. В коде ничего не менялось, но CI считает иначе.
Разрыв между «работает локально» и «работает в CI» почти всегда упирается в инфраструктуру, а не в сами тесты. Чтобы своё визуальное регрессионное тестирование в CI-конвейере работало стабильно, нужно правильно связать четыре части: CI-раннер с исполнителем Docker, контейнер, настроенный так, чтобы headless Chromium не упирался в лимит разделяемой памяти, инструмент сравнения, который выдаёт читаемые для CI отчёты, и VPS, подобранный под ту память, которую headless-браузеры действительно потребляют.
Это руководство собирает такой стек от начала до конца. В итоге у вас будет рабочий .gitlab-ci.yml (или его аналог для Gitea), окружение Docker, в котором Chromium перестаёт падать, BackstopJS, выдающий отчёты JUnit для вашего конвейера, и правильно подобранный VPS, чтобы всё это крутить.
Кратко (TL;DR)
- Запускайте браузер в одном и том же образе Docker локально и в CI. Большинство ложных срабатываний вызвано различиями шрифтов и рендеринга между окружениями. Зафиксируйте образ — и они исчезнут.
- Исправьте значение /dev/shm по умолчанию в 64MB. Размер разделяемой памяти по умолчанию в Docker не даёт рендереру Chromium развернуться, и тот молча падает в CI. Укажите shm_size: '2gb' в docker-compose.yml или передайте Chromium флаг --disable-dev-shm-usage.
- Берите BackstopJS как основной инструмент для собственного развёртывания. Он под лицензией MIT (v6.3.25), ориентирован на Docker через флаг --docker и выдаёт отчёты JUnit. Если у вас уже есть Playwright, его встроенный toHaveScreenshot() — хорошая отправная точка без установки чего-либо.
- Берите за базовый ориентир 4GB RAM. Закладывайте память с запасом. На практике задача со скриншотами в Chromium при съёмке всей страницы потребляет заметно больше памяти, чем кажется по простою, поэтому 4GB RAM — более надёжный ориентир для одной-двух последовательных или слабо параллельных задач.
Что не охватывает это руководство
Это руководство по сборке инфраструктуры, которую вы уже решили развернуть. Несколько тем сознательно вынесены за скобки:
- Это не полноценное сравнение инструментов. Здесь разбирается тот стек, который реально обслуживать в собственном CI-окружении: BackstopJS, Docker и ваш собственный раннер.
- Здесь нет self-hosted-развёртывания Argos. Argos открыт, но его публичный продуктовый сценарий и документация построены вокруг облачного приложения Argos и интеграций с CI, а не вокруг простого пути самостоятельного развёртывания в продакшене. Для задач этого руководства BackstopJS — более надёжный разобранный пример.
- Здесь не рекомендуется Lost Pixel. Проект отправлен в архив в апреле 2026 года; для новых развёртываний используйте BackstopJS.
- Здесь не пересматривается выбор между своим хостингом и управляемым SaaS. Если вы читаете это, значит Percy и Chromatic вы уже отмели.
Требования
До первой команды нужно подготовить несколько вещей:
- Собственный CI-раннер или план его развернуть (об этом ниже).
- Docker, установленный на хосте с раннером.
- Проект на Node.js с готовой целью для тестов (URL запущенного приложения или набор маршрутов компонентов для съёмки).
- Доступ к shell на VPS или хосте, где живёт раннер.
Почему визуальные тесты проходят локально и ломаются в CI
Браузер, который рисует эталон на macOS, — не тот браузер, который рисует сравнение в Linux-контейнере CI. Документация Playwright говорит об этом прямо: рендеринг в браузере зависит от OS хоста, версии, настроек, железа, источника питания, headless-режима и других факторов. Рекомендация не менее прямая: чтобы скриншоты были стабильными, запускайте тесты в том же окружении, где были сняты эталонные скриншоты.
Из одной этой рекомендации и вырастает вся остальная настройка. Практически за все ложные падения отвечают три расхождения:
- Различия шрифтов и сглаживания. На рабочей машине и в CI-контейнере разные наборы шрифтов и разный субпиксельный рендеринг. Страницы, насыщенные текстом, дают расхождения на каждом прогоне, даже если ничего не менялось.
- Headless-режим против обычного. Обычный браузер и headless могут немного по-разному раскладывать одну и ту же страницу.
- Исчерпание разделяемой памяти. Это тихий убийца. По умолчанию Docker даёт /dev/shm размером 64MB, а headless Chromium использует разделяемую память для процессов-рендереров. Когда она заканчивается, Chromium падает без внятной ошибки. Задача просто умирает или зависает.
Первые две проблемы решаются фиксацией одного образа Docker и для генерации эталона, и для сравнения. Третья решается в разделе про настройку Docker ниже. Устраните все три — и тесты, которые «плавали в CI», станут детерминированными.

Выбор инструмента: BackstopJS или встроенное сравнение Playwright
Если вы уже гоняете сквозные тесты на Playwright, самый быстрый путь — встроенная проверка toHaveScreenshot(): ноль дополнительных зависимостей, попиксельное сравнение и эталоны с суффиксом платформы из коробки. Для небольшого набора тестов на этом вполне можно остановиться, а команде примерно до пятидесяти экранов без общей библиотеки компонентов большего может и не понадобиться.
Упирается всё в процесс ревью. Сравнение в Playwright только попиксельное, UI для ревью в PR нет, поэтому принять пачку законных визуальных изменений — значит вручную перегенерировать снимки. Когда число эталонов растёт, этот ручной цикл согласования становится мучительным.
BackstopJS — специализированный инструмент, созданный именно под этот цикл. Он под лицензией MIT (сейчас v6.3.25), ориентирован на Docker через флаг --docker и выдаёт JUnit XML, который CI-раннер читает нативно. Его схема работы с эталонами (сгенерировать образец, прогнать тест, утвердить принятые расхождения как новый эталон) — та операционная модель, к которой в итоге приходит большинство собственных VRT-установок. Дальше в руководстве BackstopJS и будет разбираемым примером.
Два инструмента, которые вам могли советовать в других местах, здесь не рассматриваются: Argos не разбирается, потому что его публичный продуктовый сценарий выстроен вокруг облачного приложения и интеграций с CI, а Lost Pixel отправлен в архив. BackstopJS — более надёжный разбираемый пример для практичной собственной установки.
| Ось | BackstopJS | Playwright toHaveScreenshot() |
|---|---|---|
| Процесс ревью в PR | Встроенный отчёт и команда approve | Ручная перегенерация снимков |
| Управление эталонами | Команды reference / test / approve | Файлы снимков по каждому тесту |
| Рендеринг, нормализованный через Docker | Флаг --docker | Загрузите свой образ |
| Затраты на установку | Отдельная зависимость | Уже есть, если вы используете Playwright |
Более подробное сравнение по большему числу инструментов и критериев — наш разбор BackstopJS, Argos и Lost Pixel , где выбор рассмотрен целиком.
Развёртывание собственного CI-раннера на VPS
За стабильность рендеринга реально отвечает исполнитель Docker: раннер запускает каждую задачу внутри контейнера, который описали вы, а значит окружение браузера каждый раз одинаковое. Платформу для раннера выбирайте по тому, что ещё вы хотите на неё повесить.
Собственный GitLab CE — вариант потяжелее: репозитории, CI/CD, реестр образов, задачи и merge-запросы в одном экземпляре. Для этого руководства важна часть про исполнитель Docker в GitLab Runner, который позволяет каждой задаче визуальной регрессии запускаться внутри одного и того же зафиксированного образа. GitLab, Gitea, Jenkins, Forgejo, Portainer и Docker доступны как развёртывания в один клик в маркетплейс Cloudzy, что заметно сокращает настройку.
Gitea с act-runner — лёгкая альтернатива. Gitea — это Git-сервис на Go с движком воркфлоу, совместимым с GitHub Actions (через act-runner), так что если команда привыкла к синтаксису Actions, это компактный и быстрый в развёртывании раннер.
Что бы вы ни выбрали, цель настройки одна: раннер, который принимает задачи и выполняет их через исполнитель Docker. Всё дальнейшее (конфигурация контейнера, BackstopJS, пример конвейера) исходит из того, что этот исполнитель уже настроен.
Настройка Docker, чтобы Chromium не падал
Вот сбой, который отнимает у людей больше всего времени. Docker по умолчанию монтирует /dev/shm размером 64MB. Headless Chromium использует этот раздел разделяемой памяти для процессов-рендереров, а при съёмке всей страницы ему нужно куда больше, чем 64MB. Когда память заканчивается, рендерер умирает, зачастую без единого сообщения, указывающего на разделяемую память. Задача зависает, отваливается по таймауту или сообщает о безликом падении браузера.
Есть два решения, и работает любое из них.
Решение A: увеличить размер разделяемой памяти в Compose. Ключ shm_size задаёт размер раздела /dev/shm внутри контейнера. справочник по файлу Docker Compose от Docker подтверждается, что shm_size настраивает объём разделяемой памяти, доступной контейнеру сервиса. Задайте его в блоке сервиса:
# docker-compose.yml
services:
vrt:
image: backstopjs/backstopjs:6.3.25
shm_size: '2gb' # override the 64MB default
volumes:
- ./:/src
working_dir: /src
Решение B: сказать Chromium вообще не использовать /dev/shm. Флаг --disable-dev-shm-usage заставляет Chromium писать файлы разделяемой памяти в /tmp вместо соответствующего раздела. Передайте его в аргументах запуска браузера. В конфигурации backstop.json это engineOptions, в Playwright — launchOptions:
// backstop.json (fragment)
{
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
Решение A чище, когда Compose-файл в вашем распоряжении; решение B универсальнее, когда вы можете править только флаги запуска браузера. Применить оба тоже не повредит.
Совет: Используйте абсолютно один и тот же образ Docker локально и в CI. BackstopJS даёт это бесплатно через флаг --docker: съёмка выполняется внутри зафиксированного эталонного образа, поэтому ваша машина и раннер рендерят одинаково. Именно это убирает ложные срабатывания из-за шрифтов и сглаживания: зафиксируйте образ один раз и перестаньте гоняться за призрачными расхождениями.

Настройка BackstopJS для CI
BackstopJS формирует отчёт JUnit XML, по которому CI-раннер решает, прошла сборка или нет, — ради этого его и встраивают в конвейер, а не запускают руками. Установите его и создайте конфигурацию:
npm install --save-dev backstopjs
npx backstop init
Затем укажите в backstop.json страницы для съёмки и включите отчёт для CI:
// backstop.json (fragment)
{
"id": "my_app_vrt",
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 }
],
"scenarios": [
{
"label": "Homepage",
"url": "http://app:3000/",
"selectors": ["document"]
}
],
"report": ["CI"],
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
Именно настройка отчёта для CI выдаёт JUnit XML. При локальном запуске этих команд с хост-машины добавляйте флаг --docker. Внутри CI-задачи, которая и так работает в образе backstopjs/backstopjs, запускайте backstop test напрямую. Работа с эталонами сводится к трём командам:
npx backstop reference --docker # capture the approved baseline
npx backstop test --docker # run the comparison, emit the report
npx backstop approve # accept the current diffs as the new baseline
Одна оговорка про эталоны: каждое законное изменение UI требует запуска approve, чтобы утвердить новые скриншоты. После примерно сотни сценариев этот шаг превращается в реальные операционные издержки: кто-то должен глазами просмотреть расхождения и решить, какие из них задуманы, и нагрузка растёт вместе с числом людей в команде. Это сопровождение и есть настоящая цена визуального регрессионного тестирования на масштабе, и её стоит заложить заранее, до того как вы разгоните большой набор тестов.
Рабочая конфигурация CI-конвейера
Конвейер логически делится на две части: один раз (или по требованию) сгенерировать эталон, а затем сравнивать с ним при каждом изменении. Ниже — конфигурация GitLab CI с исполнителем Docker, где исправление разделяемой памяти уже применено через флаги запуска в backstop.json.
# .gitlab-ci.yml
stages:
- visual-regression
visual_regression:
stage: visual-regression
image:
name: backstopjs/backstopjs:6.3.25
entrypoint: [""]
script:
- backstop test
artifacts:
when: always
reports:
junit: backstop_data/ci_report/xunit.xml
paths:
- backstop_data/bitmaps_test
- backstop_data/html_report
expire_in: 1 week
Строка junit передаёт отчёт GitLab, чтобы падения были видны в UI merge-запроса; строка paths сохраняет картинки с расхождениями, чтобы можно было посмотреть, что именно изменилось. В Gitea с act-runner те же шаги ложатся на воркфлоу в стиле Actions:
# .gitea/workflows/visual-regression.yaml
name: Visual Regression
on: [push, pull_request]
jobs:
vrt:
runs-on: ubuntu-latest
container:
image: backstopjs/backstopjs:6.3.25
options: --shm-size=2gb # raise /dev/shm for the job container
steps:
- uses: actions/checkout@v4
- run: backstop test
Обратите внимание на опцию контейнера --shm-size=2gb в воркфлоу Gitea. Это то же исправление разделяемой памяти, что и решение A, но применённое на уровне контейнера задачи, где нет отдельного Compose-файла для правки.
Подбор VPS под headless Chromium
Ограничивает здесь RAM, а не CPU. Нагрузка headless Chromium на CPU импульсная (пик во время съёмки и простой между ними), поэтому скромного числа ядер вполне хватает. Именно память — тот потолок, который определяет, сколько задач вы запустите одновременно. Процесс headless Chromium держится около 300–500MB в простое и поднимается до 1–2GB при съёмке всей страницы, так что подбор конфигурации определяется объёмом RAM на каждую параллельную задачу.
| RAM | Для чего подходит |
|---|---|
| 2GB | Впритык: одна последовательная задача |
| 4GB | Рекомендуемый ориентир: 1–2 параллельные задачи |
| 8GB | 3–4 параллельные задачи |
| 16GB | Большие наборы тестов и параллельные конвейеры |
Эти цифры — практические ориентиры из реальных прогонов CI, а не вендорский бенчмарк, поэтому считайте их приблизительной отправной точкой и следите за собственными пиками потребления памяти.
Главный вывод раздела: надёжный ориентир — 4GB RAM; добавляйте примерно 2GB запаса на каждую дополнительную параллельную задачу визуальной регрессии.
Подвох самостоятельно размещаемого VRT в том, что раннер, которому не хватает памяти, не падает с громкой ошибкой — он молча роняет Chromium, ровно тот симптом, ради устранения которого вся эта конструкция и существует. Заложить достаточный запас памяти — самая дешёвая страховка из возможных. Если собирать хост для раннера с нуля не хочется, в маркетплейсе Cloudzy есть развёртывания в один клик для GitLab на собственном сервере и Gitea на собственном сервере , которые за несколько минут дают работающий экземпляр CI на VPS, конфигурацию которого можно подобрать ровно под тот запас для headless Chromium, что нужен вашему набору тестов.

Часто задаваемые вопросы
Как настроить визуальное регрессионное тестирование в конвейере CI/CD?
Установите BackstopJS, укажите в backstop.json нужные страницы и включите опцию отчёта для CI, чтобы получать JUnit XML. При запуске с локальной машины используйте backstop reference --docker и backstop test --docker, чтобы скриншоты рендерились внутри зафиксированного образа Docker. В CI запускайте задачу в образе backstopjs/backstopjs и вызывайте backstop test напрямую, а затем публикуйте отчёт JUnit из backstop_data/ci_report/xunit.xml. Используйте собственный раннер с исполнителем Docker и не менее 4GB RAM для headless Chromium.
Сколько RAM нужно headless Chromium в CI-контейнере?
Процесс headless Chromium потребляет примерно 300–500MB в простое и подскакивает до 1–2GB при съёмке всей страницы. Для стабильного CI закладывайте около 4GB RAM на одну-две параллельные задачи и примерно по 2GB на каждую следующую. Нагрузка на CPU импульсная и ограничением не является; сколько задач вы запустите одновременно, определяет именно RAM.
Почему визуальные регрессионные тесты проходят локально, но падают в CI?
Рендеринг в браузере зависит от окружения: шрифты, сглаживание, headless-режим против обычного и сама OS меняют результат. Решение — генерировать эталон и сравнивать с ним в одном и том же образе Docker, чтобы рендеринг был идентичным. Флаг --docker в BackstopJS делает именно это, выполняя съёмку внутри зафиксированного эталонного образа.
Как исправить ошибку /dev/shm в Chromium при работе в Docker и CI?
По умолчанию Docker даёт /dev/shm размером 64MB, а этого мало для рендерера headless Chromium, отсюда молчаливые падения. Либо увеличьте объём через shm_size: '2gb' в docker-compose.yml (или --shm-size=2gb для контейнера задачи), либо передайте --disable-dev-shm-usage во флагах запуска Chromium, чтобы он писал файлы разделяемой памяти в /tmp.
Что выбрать: Playwright toHaveScreenshot или отдельный инструмент визуальной регрессии?
Встроенного toHaveScreenshot() в Playwright хватает для небольших наборов: примерно до пятидесяти экранов, без общей библиотеки компонентов и без потребности в UI для ревью в PR. Переходите на отдельный инструмент вроде BackstopJS, когда нужен полноценный процесс утверждения эталонов, отчёт для ревью и нормализованный через Docker рендеринг — это становится важным по мере роста числа эталонов.
Развивается ли ещё Lost Pixel?
Нет. Lost Pixel отправлен в архив в апреле 2026 года и для новых развёртываний не подходит. Для собственной установки визуального регрессионного тестирования используйте BackstopJS.