Python: Selenium

Теория: Параллельный запуск и конфигурация

Зачем нужен параллельный запуск

Когда тестов становится сотни, их проверка может занимать часы. Каждый тест открывает браузер, переходит на страницу, кликает кнопки — всё это занимает время. Если выполнять их последовательно, один за другим, прогон превращается в длинную очередь.

Параллельный запуск решает эту проблему. Pytest может делить тесты между несколькими процессами (или даже компьютерами), и каждый процесс запускает свою копию браузера. В итоге 100 тестов, которые раньше шли 50 минут, можно прогнать за 10–15 минут — в зависимости от количества ядер и ресурсов.

Как работает pytest-xdist

pytest-xdist — это плагин, который добавляет Pytest возможность запускать тесты в несколько потоков. Устанавливается он просто:

uv add pytest-xdist

Запускается тоже просто — достаточно добавить параметр -n, где указывают число параллельных процессов:

uv run pytest -n 4

Это значит: "запусти тесты в 4 процессах одновременно". Можно не указывать число вручную — флаг auto подберёт количество по числу ядер процессора:

uv run pytest -n auto

Что происходит при параллельном запуске

Pytest запускает несколько копий себя и раздаёт тесты по процессам. Каждый процесс получает свою часть набора и работает независимо: открывает свой браузер, пишет свои логи, сохраняет свои скриншоты.

Чтобы тесты не мешали друг другу, важно, чтобы они не делили один и тот же файл, профиль браузера или логин. Например:

  • У каждого процесса своя папка screenshots/worker1, screenshots/worker2 и т.д.
  • Браузер создаётся заново в каждом тесте или хотя бы в каждом процессе.
  • Данные (например, пользователи) должны быть уникальными, чтобы не было коллизий.

Простой пример

Допустим, есть 10 тестов логина. Обычно они выполняются один за другим. Но если добавить pytest -n 5, Pytest разделит их на 5 групп и запустит 5 браузеров параллельно. Каждый процесс выполнит по два теста, и общее время сократится в 4–5 раз.

uv run pytest -n 5 tests/auth/

Как избежать конфликтов

Если все процессы используют один и тот же профиль браузера, начнутся ошибки: одни тесты будут закрывать вкладки других, куки смешаются, появятся непредсказуемые падения.

Чтобы этого не было, в фикстуре создаётся уникальный профиль для каждого процесса. Pytest предоставляет специальную переменную worker_id (например, gw0, gw1), по которой можно различать процессы.

import pytest, tempfile, shutil
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

@pytest.fixture
def driver(request):
    worker = getattr(request.config, "workerinput", {}).get("workerid", "gw0")
    profile = tempfile.mkdtemp(prefix=f"profile-{worker}-")

    opts = Options()
    opts.add_argument(f"--user-data-dir={profile}")
    opts.add_argument("--window-size=1366,768")
    opts.add_argument("--headless=new")

    driver = webdriver.Chrome(options=opts)
    yield driver

    driver.quit()
    shutil.rmtree(profile, ignore_errors=True)

Теперь каждый процесс работает со своей чистой копией браузера.

Пример запуска

uv run pytest -n auto --dist=loadscope -vv

Флаг --dist=loadscope говорит: “все тесты одного модуля или класса выполняются в одном процессе”, чтобы не ломались фикстуры с областью class.

Когда параллельный запуск не нужен

Параллельный режим даёт прирост скорости, но не подходит, если:

  • тесты используют один и тот же аккаунт или общие данные,
  • внешний сервис не выдерживает несколько одновременных логинов,
  • стенд падает при множественных запросах.

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

Передача параметров через командную строку (--browser=chrome)

Когда автотесты должны запускаться в разных браузерах, неудобно менять код вручную перед каждым прогоном. Гораздо проще управлять этим через параметры командной строки. Pytest умеет принимать собственные аргументы — например, --browser=chrome или --env=stage, — и использовать их внутри фикстур.

Как добавить пользовательский параметр

Pytest позволяет объявить новые опции через специальную функцию pytest_addoption. Обычно её добавляют в conftest.py.

## tests/conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.firefox.options import Options as FirefoxOptions

def pytest_addoption(parser):
    """Добавляет аргумент --browser в командную строку"""
    parser.addoption(
        "--browser",
        action="store",
        default="chrome",
        help="Браузер для запуска тестов: chrome или firefox"
    )

@pytest.fixture
def driver(request):
    """Создаёт WebDriver в зависимости от переданного параметра"""
    browser_name = request.config.getoption("--browser")

    if browser_name == "chrome":
        opts = ChromeOptions()
        opts.add_argument("--window-size=1366,768")
        opts.add_argument("--headless=new")
        driver = webdriver.Chrome(options=opts)

    elif browser_name == "firefox":
        opts = FirefoxOptions()
        opts.add_argument("-headless")
        driver = webdriver.Firefox(options=opts)

    else:
        raise ValueError(f"Неизвестный браузер: {browser_name}")

    yield driver
    driver.quit()

Как использовать

Теперь тесты можно запускать в любом браузере без изменения кода:

pytest --browser=chrome
pytest --browser=firefox

Если не указать параметр, по умолчанию запустится Chrome — потому что в pytest_addoption задано default="chrome".

Что делает фикстура

Фикстура driver получает имя браузера из параметра --browser. Pytest сохраняет этот параметр в request.config, и его можно считать в любом месте кода. На основе значения создаётся нужный экземпляр WebDriver:

  • webdriver.Chrome(options=opts) для Chrome;
  • webdriver.Firefox(options=opts) для Firefox.

Таким образом один и тот же тест работает с любым браузером.

Пример теста

## tests/test_login.py
def test_login_page(driver):
    driver.get("https://the-internet.herokuapp.com/login")
    assert "Login" in driver.title

При запуске Pytest сам подставит драйвер, соответствующий параметру --browser.

Пример запуска с отчётом

Можно также комбинировать опции:

pytest --browser=firefox -vv --tb=short --alluredir=reports

Pytest выполнит все тесты в Firefox, выведет подробный лог и сохранит отчёт для Allure.

Дополнительные параметры

Аналогичным образом можно добавить другие настройки:

  • --env=stage — выбор стенда;
  • --headless — включение headless-режима;
  • --lang=ru-RU — язык интерфейса;
  • --window-size=1366,768 — размер окна.

Пример с несколькими опциями:

def pytest_addoption(parser):
    parser.addoption("--browser", action="store", default="chrome")
    parser.addoption("--headless", action="store_true")
    parser.addoption("--lang", action="store", default="en-US")

@pytest.fixture
def driver(request):
    browser = request.config.getoption("--browser")
    headless = request.config.getoption("--headless")
    lang = request.config.getoption("--lang")

    if browser == "chrome":
        from selenium.webdriver.chrome.options import Options
        opts = Options()
        opts.add_argument(f"--lang={lang}")
        if headless:
            opts.add_argument("--headless=new")
        driver = webdriver.Chrome(options=opts)

    elif browser == "firefox":
        from selenium.webdriver.firefox.options import Options
        opts = Options()
        if headless:
            opts.add_argument("-headless")
        driver = webdriver.Firefox(options=opts)

    else:
        raise ValueError(f"Неизвестный браузер: {browser}")

    yield driver
    driver.quit()

Теперь можно комбинировать параметры:

pytest --browser=chrome --lang=ru-RU --headless

Управление конфигурацией через .env и pytest.ini

Тестовый проект живёт на разных стендах и в разных окружениях. Одни настройки отвечают за поведение Pytest, другие — за секреты и параметры браузера. Удобно развести эти миры: всё, что связано с Pytest и структурой запуска, хранится в pytest.ini; всё, что касается адресов, логинов, ключей и флагов браузера, уходит в .env.

Что кладут в pytest.ini

Файл pytest.ini задаёт правила работы тестового раннера. Здесь фиксируются пути к тестам, формат трейсбека, уровень логов, наборы меток и дефолтные флаги запуска. Это не секретные данные и не зависят от окружения пользователя. Пример минимальной конфигурации показывает типичные поля.

## pytest.ini
[pytest]
testpaths = tests
addopts = -vv --tb=auto
log_cli = true
log_cli_level = INFO
markers =
    smoke: быстрые проверки ключевых путей
    regression: полный набор регресса
    auth: сценарии авторизации
filterwarnings =
    ignore::DeprecationWarning

Если проект гоняется параллельно, сюда часто добавляют режим распределения и количество воркеров по умолчанию. На локальной машине удобно оставить одиночный запуск, а в CI прокинуть собственные флаги через переменные, чтобы не менять файл.

Что кладут в .env

Файл .env хранит параметры окружения, которые меняются от стенда к стенду. Это базовые URL, логины технических пользователей, флаги браузера, каталоги артефактов. Его не коммитят; в репозитории лежит только .env.example с пустыми значениями. Пример рабочего файла показывает типичный набор.

## .env
BASE_URL=https://the-internet.herokuapp.com
BROWSER=chrome
HEADLESS=true
LANG=ru-RU
WINDOW_SIZE=1366,768
DOWNLOAD_DIR=./artifacts/downloads
USER_LOGIN=tomsmith
USER_PASSWORD=SuperSecretPassword!

Загрузка .env в фикстурах

Чтение параметров удобно делать в conftest.py. Библиотека python-dotenv поднимает переменные окружения до старта тестов. Поверх .env остаётся право последнего слова за командной строкой, поэтому параметры из pytest --browser=… перекрывают значения из файла.

## tests/conftest.py
import os, pathlib, pytest
from dotenv import load_dotenv
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as CH
from selenium.webdriver.firefox.options import Options as FF

def _to_bool(x: str | None, default=False) -> bool:
    if x is None: return default
    return x.lower() in {"1","true","yes","on"}

## Поднимаем .env один раз на сессию
load_dotenv()

def pytest_addoption(parser):
    parser.addoption("--browser", action="store", default=os.getenv("BROWSER", "chrome"))
    parser.addoption("--headless", action="store", default=os.getenv("HEADLESS", "true"))
    parser.addoption("--lang", action="store", default=os.getenv("LANG", "ru-RU"))
    parser.addoption("--window-size", action="store", default=os.getenv("WINDOW_SIZE", "1366,768"))

@pytest.fixture(scope="session")
def base_url():
    return os.getenv("BASE_URL", "https://the-internet.herokuapp.com")

@pytest.fixture
def driver(request, tmp_path_factory):
    browser = request.config.getoption("--browser")
    headless = _to_bool(request.config.getoption("--headless"), True)
    lang = request.config.getoption("--lang")
    win = request.config.getoption("--window-size")

    ## уникальные каталоги для артефактов и профиля
    root = tmp_path_factory.mktemp("run")
    profile_dir = root / "profile"
    downloads = pathlib.Path(os.getenv("DOWNLOAD_DIR", root / "downloads"))
    downloads.mkdir(parents=True, exist_ok=True)

    if browser == "chrome":
        opts = CH()
        opts.add_argument(f"--user-data-dir={profile_dir}")
        opts.add_argument(f"--lang={lang}")
        opts.add_argument(f"--window-size={win}")
        opts.add_argument("--disable-notifications")
        if headless:
            opts.add_argument("--headless=new")
        prefs = {
            "download.default_directory": str(downloads.resolve()),
            "download.prompt_for_download": False,
            "plugins.always_open_pdf_externally": True
        }
        opts.add_experimental_option("prefs", prefs)
        drv = webdriver.Chrome(options=opts)

    elif browser == "firefox":
        opts = FF()
        if headless:
            opts.add_argument("-headless")
        drv = webdriver.Firefox(options=opts)

    else:
        raise ValueError(f"Неизвестный браузер: {browser}")

    yield drv
    drv.quit()

Здесь порядок источников очевиден. Значения по умолчанию зашиты в коде. .env перекрывает дефолты. Параметры из командной строки перекрывают .env. Такой каскад даёт гибкость: разработчик запускает локально «как в .env», а пайплайн передаёт свои флаги, не трогая файлы.

Доступ к секретам без утечек

Учётки и токены не попадают в код. Чтение происходит через os.getenv, а значения прокидываются в шаги или Page Object. Для демонстрации достаточно отдать логин и пароль авторизации из отдельной фикстуры.

@pytest.fixture(scope="session")
def creds():
    return {
        "login": os.getenv("USER_LOGIN", ""),
        "password": os.getenv("USER_PASSWORD", "")
    }

Страница логина ничего не знает о .env; она получает готовые значения и вводит их через Selenium.

Несколько .env под разные стенды

Команда выбирает активный файл на старте. Самый простой приём — переменная ENV_FILE. Перед запуском тестов указывается путь, а загрузка .env читает нужный файл.

## в conftest.py вместо простого load_dotenv()
from dotenv import load_dotenv, find_dotenv
env_file = os.getenv("ENV_FILE", ".env")
load_dotenv(find_dotenv(env_file), override=True)

На локальной машине лежит .env. Для стейджа в CI прокидывается ENV_FILE=.env.stage. Для контейнера — .env.ci. Код фикстур не меняется.

Комбинация с pytest.ini без конфликтов

pytest.ini остаётся про Pytest: пути, метки, формат логов, политика трейсбека. .env остаётся про окружение браузера и стенды. При необходимости некоторые параметры можно продублировать в addopts, но секреты и URL туда не идут. В результате конфигурация прозрачна: тесты читают адрес стенда из .env, раннер берёт формат вывода из pytest.ini, а любой человек может переопределить поведение одной командой в терминале.

## локальный запуск как в .env
pytest -vv

## тот же запуск, но в Firefox и без headless
pytest -vv --browser=firefox --headless=false

## прогон в CI со своим env-файлом
ENV_FILE=.env.ci pytest -n auto -vv --tb=short

Такой подход снимает две боли сразу. Настройки Pytest не смешиваются с секретами и адресами. А параметры окружения можно переключать без правок кода: достаточно изменить .env

Что значит параллельность в тестах

Когда Pytest запускает тесты с флагом -n, он просто создаёт несколько процессов (воркеров). Каждый процесс работает независимо: открывает свой браузер, выполняет тесты и пишет логи. То есть параллельность = количество браузеров, запущенных одновременно.

Если указать pytest -n 10, то будет 10 браузеров. Каждый ест память, процессор и немного видеоресурсов. Если ресурсов не хватает — система начнёт тормозить, браузеры зависнут, тесты станут нестабильными.

Почему нельзя запустить тысячу браузеров

Потому что физическая машина не справится. Один браузер в headless-режиме может занимать 300–600 МБ памяти. 1000 браузеров — это сотни гигабайт RAM и огромная нагрузка на CPU. Даже мощный сервер не выдержит такой объём.

Обычно границы такие:

  • ноутбук / разработческая машина: 2–4 параллельных браузера;
  • офисный сервер (8–16 ядер, 32 ГБ RAM): 8–16 воркеров;
  • CI-сервер или облако: 20–50 браузеров, если ресурсы распределены между узлами.

Всё, что выше — делается через распределённую инфраструктуру вроде Selenium Grid, Selenoid или BrowserStack, где браузеры запускаются не на одном компьютере, а на разных машинах или контейнерах.

Что такое «стенд»

Стенд — это тестовое окружение, копия реального сервера или сайта, на котором гоняются тесты. Например, у компании может быть:

  • prod — реальный сайт для пользователей;
  • stage — тестовый сервер для QA;
  • dev — внутренний сервер для разработчиков.

На стенде хранятся тестовые данные, отдельная база, свои логины и конфигурации. Тесты не должны работать на продакшене, чтобы не трогать настоящих пользователей.

Как обычно распределяют нагрузку

Если тестов много, их делят по стендам или узлам:

  • каждый узел — это отдельная машина с браузерами;
  • общий координатор (pytest-xdist, Jenkins, Grid, Selenoid) раздаёт тесты на эти узлы;
  • результаты собираются обратно в один отчёт.

Например:

pytest -n 4            # четыре воркера на одной машине
pytest --dist loadscope -n auto
pytest --maxfail=5 -x  # оборвать при первых пяти падениях

Если ресурсов мало — параллельность наоборот снижает стабильность. Тогда лучше запустить 2–3 процесса, но получить предсказуемый результат.

Параллельность — это компромисс:

  • чем больше ядер и памяти, тем больше тестов можно гонять одновременно;
  • но каждый браузер должен быть изолирован (отдельный профиль, куки, папки артефактов);
  • и тесты должны идти на тестовом стенде, а не на живом проде.

Главное правило: параллельно можно столько, сколько позволяет железо и инфраструктура, не ломая стабильность тестов.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845