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-прогон без риска потерять тест из-за опечатки.