pytest и фикстуры

Фикстура — это не «модный аналог setUp», а другая модель: тест сам заказывает то, что ему нужно, и получает это собранным и убранным за собой.

Фикстура (fixture) — функция, которая готовит ресурс для теста (браузер, залогиненного пользователя, тестовые данные) и гарантированно убирает его за собой после — даже если тест упал.

Зачем это на практике

В прошлом уроке тест сам создавал драйвер и сам вызывал driver.quit(). Мы уже отметили дыру: если assert падает, до quit() дело не доходит, и процесс chromedriver вместе с окном Chrome остаётся жить. На локальной машине это раздражает, на CI-агенте — убивает: после сотни упавших тестов агент захлёбывается по памяти и начинает валить уже исправные тесты. Классика расследования «почему у нас flaky-тесты» заканчивается именно тут.

Плюс есть вопросы попроще: как запускать браузер headless (без окна) на CI и с окном локально? Как не копировать настройку размера окна в тридцать файлов? Как делать скриншот в момент падения, а не после? Все ответы — фикстуры.

conftest.py — общая мастерская проекта

conftest.py — файл со специальным именем: pytest подхватывает его автоматически, импортировать ничего не надо. Всё, что в нём объявлено, доступно тестам в этой папке и во всех вложенных. Классическая структура проекта:

selenium-tests/
  conftest.py          # фикстуры для всего проекта
  pytest.ini           # маркеры и настройки запуска
  pages/
    base_page.py
    login_page.py
  tests/
    conftest.py        # фикстуры только для тестов
    test_login.py
    test_cart.py

Фикстура драйвера: setup, yield, teardown

# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


def pytest_addoption(parser):
    parser.addoption('--headless', action='store_true', help='браузер без окна')
    parser.addoption('--base-url', action='store', default='https://demo.codechick.io')


@pytest.fixture(scope='session')
def base_url(request):
    return request.config.getoption('--base-url')


@pytest.fixture
def driver(request):
    options = Options()
    if request.config.getoption('--headless'):
        options.add_argument('--headless=new')
    options.add_argument('--window-size=1440,900')

    driver = webdriver.Chrome(options=options)   # setup
    yield driver                                 # тело теста выполняется здесь
    driver.quit()                                # teardown, выполнится в любом случае

Разберём по строкам. pytest_addoption добавляет ключи в командную строку: локально запускаем pytest, на CI — pytest --headless, и ни один тест об этом не знает. Всё до yield — подготовка. Сам yield отдаёт готовый драйвер тесту. Всё после yield — уборка, и она выполнится даже если тест упал с исключением: pytest оборачивает вызов в аналог try/finally. Браузеры-зомби исчезают как класс.

Тест теперь просто просит драйвер, объявив его в аргументах. Никакой магии импорта: pytest сопоставляет имя аргумента с именем фикстуры.

# tests/test_login.py
from pages.login_page import LoginPage


def test_uspeshnyy_vhod(driver, base_url):
    dashboard = LoginPage(driver, base_url).open().login_as('anna@example.com', 'S3cret-pass')
    assert 'Анна' in dashboard.current_user

Фикстуры зависят от фикстур

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

# tests/conftest.py
import pytest

from pages.login_page import LoginPage


@pytest.fixture
def login_page(driver, base_url):
    return LoginPage(driver, base_url).open()


@pytest.fixture
def dashboard(login_page):
    """Тест начинается уже под залогиненным пользователем."""
    return login_page.login_as('anna@example.com', 'S3cret-pass')


def test_v_korzine_pusto_u_novogo_polzovatelya(dashboard):
    assert dashboard.open_cart().items_count == 0

Тест test_v_korzine_pusto... не содержит ни строчки про вход — он объявил, что ему нужен dashboard, и pytest сам построил цепочку driver → login_page → dashboard. Это композиция вместо наследования, и она несравнимо гибче, чем один общий setUp на класс.

scope: сколько живёт ресурс

scopeСоздаётся зановоКогда применять в UI-тестах
function (по умолчанию)на каждый тестДрайвер. Чистый браузер = независимые тесты. Дефолтный и правильный выбор.
classраз на классДлинный сценарий, разбитый на шаги внутри одного класса.
moduleраз на файлТяжёлые данные для группы тестов одного файла.
sessionраз на весь прогонТокен API, тестовый пользователь, чтение конфига — то, что не меняется.

Соблазн сделать драйвер scope='session' велик: запуск Chrome занимает секунду-полторы, на 200 тестах это минуты. Но цена — общее состояние: куки, localStorage, открытые модалки. Один тест оставил мусор — следующий падает, причём падает иногда, в зависимости от порядка. Отлаживать такое мучительно. Правильный способ ускориться — не общий драйвер, а параллельный запуск: pytest -n 4 с плагином pytest-xdist поднимет четыре независимых браузера.

Маркеры: запускать не всё подряд

# pytest.ini
[pytest]
addopts = -ra --strict-markers
markers =
    smoke: быстрые проверки на каждый коммит
    slow: длинные сценарии, только ночью
    flaky: нестабильные, разбираемся
import pytest


@pytest.mark.smoke
def test_uspeshnyy_vhod(driver):
    ...


@pytest.mark.slow
def test_polnyy_scenariy_pokupki(driver):
    ...
pytest -m smoke            # только дым, ~2 минуты, на каждый пуш
pytest -m "not slow"       # всё, кроме долгого
pytest -m "smoke or slow"  # объединение

Ключ --strict-markers обязателен: без него опечатка @pytest.mark.smoek просто ничего не отметит, и тест тихо выпадет из smoke-прогона. С ним pytest упадёт с ошибкой на старте — именно то, что нужно.

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

Перед запуском теста pytest смотрит на имена его аргументов, ищет фикстуры с такими именами (сначала в файле теста, потом в ближайшем conftest.py, потом выше по дереву папок, потом в плагинах) и строит граф зависимостей. Каждая фикстура вычисляется один раз в рамках своего scope и кэшируется: если login_page и dashboard обе просят driver, браузер запустится один, а не два раза. После теста pytest сворачивает граф в обратном порядке, выполняя код после yield.

Знание «упал ли тест» доступно фикстуре через хук — благодаря этому скриншот делается ровно в момент падения, пока браузер ещё жив:

# conftest.py
import pathlib

import pytest


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    setattr(item, 'report_' + report.when, report)


@pytest.fixture
def driver(request):
    driver = webdriver.Chrome()
    yield driver

    report = getattr(request.node, 'report_call', None)
    if report is not None and report.failed:
        pathlib.Path('artifacts').mkdir(exist_ok=True)
        driver.save_screenshot(f'artifacts/{request.node.name}.png')

    driver.quit()

Почему фикстура лучше setUp из unittest

Критерийunittest setUpФикстура pytest
Как получить ресурсНаследоваться от TestCase, читать self.driverОбъявить аргумент — pytest подставит сам
Разные наборы ресурсовМножественное наследование или один толстый setUp на всехКаждый тест просит ровно то, что нужно ему
УборкаОтдельный tearDown, легко рассинхронизировать с setUpКод после yield — рядом с созданием
Время жизниТолько «на тест» и setUpClassЧетыре scope, включая session
Параметризация подготовкиНет@pytest.fixture(params=[...]) размножит тесты

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

  • Вернуть драйвер через return вместо yield. Тогда teardown-кода просто нет и браузеры копятся. Помните: return — фикстура без уборки, yield — с уборкой.
  • scope='session' у драйвера ради скорости. Получаете зависимость тестов от порядка запуска и плавающие падения. Ускоряйтесь через pytest-xdist.
  • Фикстуры, спрятанные в utils.py и импортируемые вручную. Работает, но ломает механизм переопределения: смысл conftest.py — в том, что вложенный файл может переопределить фикстуру для своей папки.
  • Скриншот в except внутри теста. Захламляет тест и всё равно не поймает падение в фикстуре. Хук pytest_runtest_makereport решает задачу один раз для всего проекта.
  • Забыть --strict-markers. Опечатка в имени маркера тихо выкидывает тест из прогона — самый обидный способ потерять покрытие.

Итоги

  • Фикстура driver с yield гарантирует quit() даже при падении теста.
  • conftest.py подхватывается автоматически и действует на папку и всё, что внутри.
  • Фикстуры зависят друг от друга: driver → login_page → dashboard — композиция вместо наследования.
  • scope по умолчанию function; для драйвера это правильный выбор, скорость берём параллелизмом.
  • Маркеры плюс --strict-markers дают быстрый smoke-прогон без риска потерять тест из-за опечатки.
Проверьте себя
1. Что произойдёт, если в фикстуре драйвера использовать return driver вместо yield driver?
AНичего, это полные синонимы
BТест не получит драйвер и упадёт с ошибкой
CДрайвер будет создан, но код уборки (quit) не выполнится — браузеры останутся висеть
Dpytest создаст драйвер заново для каждого assert
2. Почему scope='session' для фикстуры драйвера считается опасным выбором?
ASelenium не поддерживает сессионные фикстуры
BТесты начинают делить состояние браузера (куки, localStorage) и становятся зависимыми от порядка запуска
CSession-фикстуры выполняются медленнее function-фикстур
DВ этом случае pytest не покажет отчёт о падении