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), за собой убирайте.
  • Параллель не создаёт флаки-тесты — она их проявляет.
Проверьте себя
1. Что произойдёт с фикстурой scope="session" при запуске pytest -n 4?
AОна выполнится один раз и результат будет общим для всех воркеров
BОна выполнится по одному разу в каждом из 4 процессов-воркеров
Cpytest откажется запускаться и выдаст ошибку конфигурации
DОна вообще не выполнится — xdist игнорирует session-фикстуры
2. Тесты используют один общий аккаунт qa@example.com. При pytest -n 4 они начали падать через раз. Что делать?
AДобавить в тесты time.sleep(5), чтобы они не пересекались
BВключить --dist loadscope и оставить общий аккаунт
CНастроить перезапуск упавших тестов через --reruns 2
DСоздавать уникального пользователя на каждый тест в фикстуре (например, через API и uuid4) и удалять его после