Headless-режим и запуск в CI

Тесты, которые бодро бегали на вашем ноутбуке, должны так же надёжно бегать на сервере, где нет ни монитора, ни мышки, ни вашего залогиненного профиля Chrome.

Headless-режим — запуск браузера без графической оболочки: движок рендеринга, JavaScript, сеть и DOM работают полностью, но окно на экран не выводится.

Зачем это нужно

Сервер сборки — это обычно контейнер Linux без графической подсистемы. Там просто некому нарисовать окно браузера: нет X-сервера, нет дисплея. Попытка запустить обычный Chrome закончится честной ошибкой вроде unable to discover open pages или Chrome failed to start: exited abnormally. Headless решает эту проблему на корню: браузер тот же самый, просто он не рисует пиксели на экран, а держит «страницу» в памяти.

Есть и второй мотив, менее очевидный. Headless-браузер дешевле: меньше памяти, меньше CPU, быстрее старт. На одной машине можно поднять не два браузера, а восемь — а значит, прогнать набор тестов не за сорок минут, а за пять. Для команды это разница между «запускаем регресс на ночь» и «регресс проходит на каждом pull request».

И третий: воспроизводимость. Локально у вас установлены свои шрифты, свои расширения, залогиненные аккаунты, любимый масштаб 110%. В CI ничего этого нет — окружение стерильное и одинаковое у всех. Это неудобно ровно один день, а потом начинает экономить нервы.

Включаем headless

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

from selenium import webdriver
from selenium.webdriver.chrome.options import Options


def make_chrome(headless: bool = True):
    options = Options()
    if headless:
        options.add_argument("--headless=new")
    options.add_argument("--window-size=1920,1080")
    options.add_argument("--disable-gpu")
    options.add_argument("--lang=ru-RU")
    options.set_capability("pageLoadStrategy", "normal")

    driver = webdriver.Chrome(options=options)
    driver.set_page_load_timeout(30)
    return driver

Разберём построчно, потому что каждая строка здесь стоит за какой-то историей отладки.

  • --headless=new — «новый» headless, доступный с Chrome 109. Он использует ровно тот же код браузера, что и обычный режим. Старый --headless был отдельной урезанной реализацией, в которой некоторые вещи (расширения, печать, часть событий) вели себя иначе — из-за этого рождались тесты, зелёные на ноутбуке и красные в CI. Если видите в чужом коде просто --headless — это повод обновиться.
  • --window-size=1920,1080обязательная строка. По умолчанию headless-окно бывает крошечным (около 800x600). На такой ширине адаптивная вёрстка прячет меню в «бургер», кнопка «Купить» уезжает за границу вьюпорта, и Selenium честно сообщает element click intercepted. Половина всех «в CI не кликается» лечится именно здесь.
  • --disable-gpu — исторический костыль для Windows, на Linux уже не нужен, но безвреден и до сих пор кочует из проекта в проект.
  • --lang=ru-RU — фиксирует язык интерфейса и заголовок Accept-Language. Без него сайт, определяющий локаль по браузеру, покажет вам английскую версию, а тест будет искать кнопку «Оформить заказ».
  • set_page_load_timeout(30) — страховка от вечного ожидания: если страница не загрузилась за 30 секунд, тест упадёт с внятной ошибкой, а не будет висеть, пока CI не убьёт всю сборку по общему таймауту.

У Firefox всё то же самое, только через свой класс опций:

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions


def make_firefox():
    options = FirefoxOptions()
    options.add_argument("--headless")
    options.set_preference("intl.accept_languages", "ru-RU, ru")
    driver = webdriver.Firefox(options=options)
    driver.set_window_size(1920, 1080)
    return driver

Docker: откуда берётся --no-sandbox

Внутри контейнера появляется два новых сюрприза, и оба лечатся флагами.

Первый — песочница. Chrome изолирует вкладки в так называемой sandbox: отдельный процесс с урезанными правами, чтобы вредоносная страница не выбралась в систему. Для этого механизма нужны привилегии ядра (namespaces), которых у контейнера по умолчанию нет. Плюс процесс в контейнере часто идёт от root, а Chrome от root в песочницу принципиально не пускает. Итог — Chrome не стартует, и в логе появляется знаменитое Running as root without --no-sandbox is not supported.

Второй — /dev/shm. Это разделяемая память, куда Chrome складывает отрисованные кадры. Docker по умолчанию выдаёт под неё всего 64 МБ. Тяжёлая страница переполняет этот объём — и вкладка падает с session deleted because of page crash или tab crashed ровно в тот момент, когда вы меньше всего этого ждёте.

import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


def make_chrome():
    options = Options()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1920,1080")

    if os.getenv("CI") == "true":
        options.add_argument("--no-sandbox")
        options.add_argument("--disable-dev-shm-usage")

    return webdriver.Chrome(options=options)

--disable-dev-shm-usage заставляет Chrome писать временные файлы в /tmp вместо /dev/shm. Более честная альтернатива — не отключать, а увеличить объём: docker run --shm-size=2g .... Обратите внимание на условие os.getenv("CI"): почти любая CI-система (GitHub Actions, GitLab CI, Jenkins) сама выставляет переменную CI=true. Локально флаги отключения песочницы не нужны и не нужны совсем — вы же не хотите ослаблять защиту своего рабочего браузера.

Образ для прогонов собирается буквально в десять строк:

FROM python:3.12-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

CMD ["pytest", "-v"]

Пакет fonts-liberation здесь не для красоты. В голом slim-образе шрифтов нет вообще: текст рендерится квадратиками, вёрстка «плывёт», а скриншот падения превращается в ребус. Ставьте шрифты — сэкономите себе час.

Пайплайн

Осталось повесить прогон на события репозитория. Пример для GitHub Actions:

name: e2e

on:
  pull_request:
  schedule:
    - cron: "0 3 * * *"

jobs:
  ui-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - name: Run UI tests
        env:
          CI: "true"
          BASE_URL: https://staging.example.com
        run: pytest -v --junitxml=report.xml
      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-artifacts
          path: |
            report.xml
            screenshots/

Ключевая строка тут — if: always(). Без неё артефакты выгружаются только у успешной сборки, то есть ровно тогда, когда они не нужны. А скриншоты и логи упавших тестов — единственное, по чему вы потом будете разбираться в падении, которое не воспроизводится локально.

Как это работает

Изнутри цепочка выглядит так. Selenium поднимает chromedriver — маленький HTTP-сервер, говорящий на языке протокола W3C WebDriver. Ваш код отправляет ему запросы вида «создай сессию с такими опциями», «найди элемент по CSS», «кликни». Chromedriver запускает браузер, передавая ему аргументы командной строки — те самые --headless=new и --no-sandbox — и дальше управляет им через отладочный протокол.

То есть headless — это не «другой Selenium» и не эмулятор. Это один флаг, который добирается до бинарника браузера. Всё остальное — тот же движок Blink, тот же V8, те же сетевые запросы.

Начиная с Selenium 4.6 не нужно вручную скачивать драйверы: встроенный Selenium Manager сам определяет версию установленного браузера и подтягивает совместимый chromedriver. Именно поэтому из современных проектов исчез webdriver-manager, а строка webdriver.Chrome() работает «просто так».

Частые ошибки

Отдельно стоит поговорить, почему в CI тесты падают чаще, чем локально. Это не мистика, причины вполне материальные.

СимптомНастоящая причина
element click intercepted, элемент не виденКрошечный вьюпорт по умолчанию: сработала мобильная вёрстка. Задайте --window-size
Chrome failed to start: exited abnormallyНет песочницы в контейнере — нужен --no-sandbox
session deleted because of page crashПереполнен /dev/shm — нужен --shm-size=2g или --disable-dev-shm-usage
Не находится текст кнопкиДругая локаль браузера — зафиксируйте --lang
Тест «иногда» не успеваетРаннер CI слабее ноутбука и делит CPU с другими задачами. Гонки, которые локально проскакивали, в CI вылезают наружу
Всплыл баннер cookies, которого не былоЛокально вы его один раз закрыли, и он лёг в профиль. В CI профиль всегда чистый

Из этой таблицы следует важный вывод: CI не «портит» тесты — он честно вскрывает те дефекты, которые у вас уже были. Если тест зелёный только на быстрой машине с прогретым профилем, это плохой тест, а не плохой CI. Лечится это не увеличением time.sleep(), а явными ожиданиями и независимостью от состояния окружения.

И последнее по списку, но не по важности: не отключайте песочницу локально. Флаг --no-sandbox — вынужденная мера для изолированного контейнера, где браузер и так живёт в клетке. На рабочей машине он снимает реальный слой защиты.

Итоги

  • Headless — тот же браузер, только без окна: --headless=new для Chrome, --headless для Firefox.
  • --window-size=1920,1080 ставьте всегда: иначе адаптивная вёрстка спрячет половину элементов.
  • В Docker нужны --no-sandbox и решение проблемы /dev/shm (--shm-size=2g лучше, чем --disable-dev-shm-usage). Включайте их по условию CI=true, а не всегда.
  • Не забудьте шрифты в образе, иначе скриншоты падений будут бесполезны.
  • Артефакты выгружайте с if: always() — они нужны именно у красной сборки.
  • Тесты, падающие только в CI, почти всегда были нестабильны изначально: CI лишь замедлил машину и убрал ваш «прогретый» профиль.
Проверьте себя
1. Почему в headless-режиме часто появляется ошибка element click intercepted, хотя локально в обычном браузере всё кликается?
AHeadless не умеет кликать по кнопкам, нужен JavaScript-клик
BОкно headless-браузера по умолчанию маленькое, срабатывает адаптивная вёрстка и элемент уезжает или перекрывается
CВ headless отключён JavaScript, поэтому обработчики клика не навешиваются
DChromedriver в headless работает по другому протоколу
2. Зачем в Docker-контейнере Chrome запускают с флагом --no-sandbox?
AЧтобы ускорить загрузку страниц
BЧтобы Selenium мог делать скриншоты
CПотому что песочнице Chrome нужны привилегии ядра, которых нет у контейнера, а от root Chrome в песочницу не пускает
DЧтобы браузер не сохранял cookies между тестами