Selenium Grid и параллельный запуск
Сто тестов по десять секунд — это семнадцать минут ожидания. Те же сто тестов на десяти браузерах — меньше двух. Разбираемся, как раздать тесты по узлам и что для этого придётся починить в самих тестах.
Selenium Grid — сервер, который принимает запросы на создание сессий WebDriver и распределяет их по подключённым узлам (nodes), где реально запускаются браузеры. Ваш код при этом не меняется: он просто ходит по сети, а не в локальный chromedriver.
Зачем это нужно
У Grid ровно две задачи, и обе про масштаб.
Первая — скорость. UI-тесты по своей природе медленные: загрузка страницы, ожидания, анимации. Оптимизировать отдельный тест можно до какого-то предела, а дальше остаётся только один рычаг — гнать их одновременно. Grid даёт пул браузеров, куда можно послать сколько угодно параллельных сессий.
Вторая — зоопарк браузеров. Проверить, что форма работает и в Chrome, и в Firefox, локально — значит поставить себе оба браузера и оба драйвера, следить за их версиями и надеяться, что у коллеги то же самое. С Grid браузеры живут в контейнерах с зафиксированными версиями, а тест просто просит «дай мне Firefox 126».
Поднимаем Grid
Проще всего — официальными образами. Минимальная конфигурация: один hub и два типа узлов.
services:
selenium-hub:
image: selenium/hub:4.21
ports:
- "4442:4442"
- "4443:4443"
- "4444:4444"
chrome:
image: selenium/node-chrome:4.21
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=4
- SE_NODE_OVERRIDE_MAX_SESSIONS=true
firefox:
image: selenium/node-firefox:4.21
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
docker compose up -d --scale chrome=3
# консоль Grid: видно узлы, свободные слоты и живые сессии
open http://localhost:4444/ui
Пара важных деталей. shm_size: 2gb — та самая разделяемая память из прошлого урока; без неё вкладки будут падать с page crash на тяжёлых страницах. SE_NODE_MAX_SESSIONS задаёт, сколько браузеров узел готов держать одновременно; по умолчанию Grid ориентируется на число ядер, и превышать его без запаса по CPU — плохая идея: тесты начнут тормозить и «флакать» от нехватки ресурсов.
Подключаемся: Remote WebDriver
В коде меняется ровно одна строка — вместо webdriver.Chrome() используется webdriver.Remote() с адресом хаба.
import os
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
GRID_URL = os.getenv("GRID_URL") # например http://localhost:4444
@pytest.fixture
def driver():
options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
if GRID_URL:
drv = webdriver.Remote(command_executor=GRID_URL, options=options)
else:
drv = webdriver.Chrome(options=options)
drv.implicitly_wait(0)
yield drv
drv.quit()
Разберём, что здесь происходит:
- Фикстура
driverобъявлена безscope, то есть она функциональная: свой браузер на каждый тест. Это принципиально. Один общий драйвер на все тесты — прямой путь к тому, что тесты начнут «заражать» друг друга cookies и открытыми модалками. webdriver.Remoteотправляет команды по HTTP на хаб, а тот проксирует их узлу. Для вашего кода API не отличается ничем.- Один и тот же объект
optionsиспользуется в обеих ветках — благодаря этому локальный и удалённый прогон ведут себя одинаково. drv.quit()вyield-фикстуре обязателен. Не закрыли сессию — узел Grid считает слот занятым, и через десяток тестов свободных слотов не останется.
Параллелизм: pytest-xdist
Grid умеет обслуживать много сессий, но кто-то должен их одновременно попросить. Со стороны pytest это делает плагин pytest-xdist: он поднимает несколько процессов-воркеров и раздаёт им тесты.
pip install pytest-xdist
# 4 воркера
pytest -n 4
# по числу ядер
pytest -n auto
# все тесты одного файла — одному воркеру
pytest -n 4 --dist loadfile
Стратегии распределения стоит знать:
| Режим | Что делает | Когда брать |
--dist load (по умолчанию) | раздаёт тесты воркерам по мере освобождения | тесты полностью независимы |
--dist loadfile | все тесты одного файла идут одному воркеру | внутри файла есть общая тяжёлая фикстура |
--dist loadscope | группирует по классу/модулю | тесты класса делят состояние (не лучший, но встречающийся дизайн) |
Важно понимать: --dist loadfile и loadscope — это не решение проблемы связанных тестов, а способ отложить встречу с ней. Настоящее решение — сделать тесты независимыми.
Что придётся починить в тестах
Параллельный запуск — жестокий, но справедливый ревизор. Он мгновенно вскрывает любые скрытые связи между тестами. Требований к тестам, по сути, два.
1. Независимость от порядка
Классическая беда: один тест регистрирует пользователя, второй под ним логинится. Последовательно всё работает; при -n 4 второй тест улетает другому воркеру и стартует раньше первого — и падает.
# ПЛОХО: тесты связаны через глобальную переменную и порядок запуска
USER = {}
def test_register(driver):
USER["email"] = "qa@example.com"
register(driver, USER["email"], "Pa$$w0rd")
def test_login(driver):
login(driver, USER["email"], "Pa$$w0rd") # упадёт, если ушёл другому воркеру
Проверка простая: если тест нельзя запустить в одиночку командой pytest tests/test_login.py::test_login и получить зелёный — он сломан, просто вы этого пока не заметили.
2. Свои данные у каждого теста
Второй источник боли — общий тестовый аккаунт. Два воркера одновременно логинятся под qa@example.com, один чистит корзину, второй в этот момент проверяет её содержимое. Ловите «плавающее» падение, которое никогда не воспроизводится поодиночке.
import uuid
import pytest
@pytest.fixture
def user(api_client):
email = f"qa-{uuid.uuid4().hex[:10]}@example.com"
account = api_client.create_user(email=email, password="Pa$$w0rd")
yield account
api_client.delete_user(account.id)
def test_add_to_cart(driver, user):
login(driver, user.email, "Pa$$w0rd")
add_to_cart(driver, sku="BOOK-1")
assert cart_count(driver) == 1
Три вещи, которые здесь сделаны правильно: уникальный e-mail через uuid4, создание данных через API, а не через UI (быстрее и не зависит от вёрстки формы регистрации), и уборка за собой после yield.
Сюда же — уникальные имена файлов. Если каждый тест сохраняет скриншот в screenshots/fail.png, при параллельном прогоне вы получите один файл вместо четырёх. Подмешивайте в имя PYTEST_XDIST_WORKER или имя теста.
Как это работает
Grid 4 внутри разбит на роли, и полезно знать хотя бы их названия — они всплывают в логах.
- Router — единая точка входа на порту 4444: принимает HTTP-запрос от вашего
webdriver.Remote. - Distributor — знает все узлы и их свободные слоты; выбирает узел, чьи возможности подходят под запрошенные capabilities (браузер, версия, платформа).
- Session Map — хранит соответствие «идентификатор сессии → узел». Дальше все команды теста Router направляет уже точно по адресу.
- Node — машина или контейнер с браузером и драйвером, где всё реально исполняется.
Поэтому параллельность в Grid — это просто N одновременно живущих сессий. Никакой магии: сколько слотов на узлах, столько браузеров и запустится. Попросите больше — запрос встанет в очередь, а по истечении таймаута вернётся SessionNotCreatedException. Отсюда правило: число воркеров xdist имеет смысл держать не больше суммарного числа слотов Grid.
Частые ошибки
- Забыли
driver.quit(). Сессии копятся, слоты кончаются, прогон падает наSessionNotCreatedException. Закрывайте драйвер в teardown-части фикстуры — тогда он закроется даже у упавшего теста. - Воркеров больше, чем ядер.
pytest -n 16на четырёхъядерном раннере не ускорит прогон, а превратит тесты в лотерею: браузеры будут голодать по CPU, ожидания — истекать. - Фикстура со
scope="session". Легко ошибиться: xdist запускает отдельный процесс на каждого воркера, поэтому session-фикстура выполнится столько раз, сколько воркеров. Если она, например, накатывает миграции или чистит базу — получите гонку на ровном месте. - Общий тестовый аккаунт. Самая частая причина флаки при переходе на параллель. Уникальные данные на тест — не перфекционизм, а условие работоспособности.
- Ждём через
time.sleep(). Под нагрузкой узлы отвечают медленнее, и «надёжные» две секунды перестают быть надёжными. Только явные ожидания.
Итоги
- Grid — пул браузеров за сетевым API; в коде меняется одна строка:
webdriver.Remote(command_executor=GRID_URL, options=options). - Поднимается через docker-compose: hub плюс узлы
node-chrome/node-firefox, обязательно сshm_size. - Параллелизм со стороны pytest даёт
pytest-xdist:pytest -n 4. Число воркеров согласуйте со слотами Grid и ядрами. - Тест обязан быть независимым: запускаться в одиночку, в любом порядке и не делить данные с соседями.
- Данные создавайте по API, имена — уникальные (
uuid4), за собой убирайте. - Параллель не создаёт флаки-тесты — она их проявляет.