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 лишь замедлил машину и убрал ваш «прогретый» профиль.