Перейти к основному содержанию
Скидка 50% все планы, ограниченное время. Начиная от $2.48/mo
13 min left
Инструменты разработчика и DevOps

Как развернуть собственное визуальное регрессионное тестирование в CI-конвейере

S Автор: Sajjad 13 мин чтения
Self-hosted visual regression testing running in a CI pipeline with BackstopJS and Docker

Визуальные регрессионные тесты проходят у вас на ноутбуке. Вы делаете 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», станут детерминированными.

Three reasons visual regression tests pass locally but fail in CI: font and anti-aliasing differences between the dev machine and the CI container, headless versus headed rendering differences, and shared-memory exhaustion from Docker's 64MB /dev/shm default crashing headless Chromium

Выбор инструмента: BackstopJS или встроенное сравнение Playwright

Если вы уже гоняете сквозные тесты на Playwright, самый быстрый путь — встроенная проверка toHaveScreenshot(): ноль дополнительных зависимостей, попиксельное сравнение и эталоны с суффиксом платформы из коробки. Для небольшого набора тестов на этом вполне можно остановиться, а команде примерно до пятидесяти экранов без общей библиотеки компонентов большего может и не понадобиться.

Упирается всё в процесс ревью. Сравнение в Playwright только попиксельное, UI для ревью в PR нет, поэтому принять пачку законных визуальных изменений — значит вручную перегенерировать снимки. Когда число эталонов растёт, этот ручной цикл согласования становится мучительным.

BackstopJS — специализированный инструмент, созданный именно под этот цикл. Он под лицензией MIT (сейчас v6.3.25), ориентирован на Docker через флаг --docker и выдаёт JUnit XML, который CI-раннер читает нативно. Его схема работы с эталонами (сгенерировать образец, прогнать тест, утвердить принятые расхождения как новый эталон) — та операционная модель, к которой в итоге приходит большинство собственных VRT-установок. Дальше в руководстве BackstopJS и будет разбираемым примером.

Два инструмента, которые вам могли советовать в других местах, здесь не рассматриваются: Argos не разбирается, потому что его публичный продуктовый сценарий выстроен вокруг облачного приложения и интеграций с CI, а Lost Pixel отправлен в архив. BackstopJS — более надёжный разбираемый пример для практичной собственной установки.

ОсьBackstopJSPlaywright 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: съёмка выполняется внутри зафиксированного эталонного образа, поэтому ваша машина и раннер рендерят одинаково. Именно это убирает ложные срабатывания из-за шрифтов и сглаживания: зафиксируйте образ один раз и перестаньте гоняться за призрачными расхождениями.

Two fixes for headless Chromium crashing in Docker CI: raising the container's shared memory with shm_size set to 2gb in docker-compose.yml, or passing the --disable-dev-shm-usage flag so Chromium writes shared-memory files to /tmp instead of the 64MB /dev/shm partition

Настройка 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 параллельные задачи
8GB3–4 параллельные задачи
16GBБольшие наборы тестов и параллельные конвейеры

Эти цифры — практические ориентиры из реальных прогонов CI, а не вендорский бенчмарк, поэтому считайте их приблизительной отправной точкой и следите за собственными пиками потребления памяти.

Главный вывод раздела: надёжный ориентир — 4GB RAM; добавляйте примерно 2GB запаса на каждую дополнительную параллельную задачу визуальной регрессии.

Подвох самостоятельно размещаемого VRT в том, что раннер, которому не хватает памяти, не падает с громкой ошибкой — он молча роняет Chromium, ровно тот симптом, ради устранения которого вся эта конструкция и существует. Заложить достаточный запас памяти — самая дешёвая страховка из возможных. Если собирать хост для раннера с нуля не хочется, в маркетплейсе Cloudzy есть развёртывания в один клик для GitLab на собственном сервере и Gitea на собственном сервере , которые за несколько минут дают работающий экземпляр CI на VPS, конфигурацию которого можно подобрать ровно под тот запас для headless Chromium, что нужен вашему набору тестов.

VPS memory sizing guide for headless Chromium visual regression jobs: 2GB for a single sequential job, 4GB as the recommended baseline for one to two concurrent jobs, 8GB for three to four concurrent jobs, and 16GB for large suites and parallel pipelines

Часто задаваемые вопросы

Как настроить визуальное регрессионное тестирование в конвейере 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.

Поделиться

Ещё в блоге

Читайте дальше.

Готовы к развёртыванию? От $2,48/мес.

Независимое облако с 2008 года. AMD EPYC, NVMe, 40 Gbps. Возврат денег в течение 14 дней.