Параметризация и data-driven тесты
Один тест, двадцать наборов данных, двадцать строк в отчёте — и ни одного скопированного блока кода.
Data-driven тест — тест, в котором логика проверки написана один раз, а данные подаются снаружи набором. Каждый набор превращается pytest в отдельный тест-кейс со своим результатом в отчёте.
Зачем это на практике
Форма регистрации. Валидация почты: пустая, без собаки, с пробелом, кириллицей в домене, длиннее 254 символов. Валидация пароля: короткий, без цифры, совпадает с почтой. Итого десяток негативных проверок и пара позитивных. Написать двенадцать почти одинаковых функций — значит обречь себя на двенадцатикратную правку, когда изменится текст ошибки.
Соблазнительная альтернатива — цикл for внутри одного теста. Так делать нельзя, и вот почему: первый же упавший assert прервёт цикл, и остальные наборы вы просто не проверите. В отчёте будет один красный тест вместо «11 зелёных, 1 красный, вот этот». А ещё цикл не параллелится и не перезапускается по одному кейсу.
pytest.mark.parametrize
# tests/test_registration.py
import pytest
from pages.register_page import RegisterPage
@pytest.mark.parametrize(
'email, password, error',
[
('', 'S3cret-pass', 'Укажите почту'),
('anna', 'S3cret-pass', 'Некорректный адрес почты'),
('anna@ example.com', 'S3cret-pass', 'Некорректный адрес почты'),
('anna@example.com', '', 'Укажите пароль'),
('anna@example.com', '123', 'Пароль короче 8 символов'),
('anna@example.com', 'password', 'Пароль должен содержать цифру'),
],
ids=[
'pustaya-pochta',
'pochta-bez-sobaki',
'probel-v-pochte',
'pustoy-parol',
'korotkiy-parol',
'parol-bez-cifry',
],
)
def test_nevalidnaya_registraciya(driver, email, password, error):
page = RegisterPage(driver).open().submit(email, password)
assert page.error_text == error
assert page.is_current(), 'При ошибке валидации пользователь должен остаться на форме'
Разбираем. Первый аргумент parametrize — строка с именами параметров, они же станут аргументами тестовой функции. Второй — список кортежей, по кортежу на кейс. ids задаёт человеческие имена: без них pytest сгенерирует что-то вроде test_nevalidnaya_registraciya[anna@ example.com-S3cret-pass-...] — читать это в отчёте CI невозможно. С ids отчёт выглядит так:
tests/test_registration.py::test_nevalidnaya_registraciya[pustaya-pochta] PASSED
tests/test_registration.py::test_nevalidnaya_registraciya[pochta-bez-sobaki] PASSED
tests/test_registration.py::test_nevalidnaya_registraciya[probel-v-pochte] FAILED
tests/test_registration.py::test_nevalidnaya_registraciya[pustoy-parol] PASSED
tests/test_registration.py::test_nevalidnaya_registraciya[korotkiy-parol] PASSED
tests/test_registration.py::test_nevalidnaya_registraciya[parol-bez-cifry] PASSED
Сразу видно: почта с пробелом внутри проходит валидацию, хотя не должна. Это готовый баг-репорт. И перезапустить можно ровно его: pytest "tests/test_registration.py::test_nevalidnaya_registraciya[probel-v-pochte]".
Помечать отдельный кейс: pytest.param
Баг завели, чинить будут в следующем спринте, а сюита должна оставаться зелёной. Не удаляйте кейс — пометьте его:
@pytest.mark.parametrize(
'email, error',
[
pytest.param('anna', 'Некорректный адрес почты', id='bez-sobaki'),
pytest.param(
'anna@ example.com',
'Некорректный адрес почты',
id='probel-v-pochte',
marks=pytest.mark.xfail(reason='BUG-412: пробел проходит валидацию'),
),
],
)
def test_validaciya_pochty(driver, email, error):
page = RegisterPage(driver).open().submit(email, 'S3cret-pass')
assert page.error_text == error
xfail означает «ожидаемо падает»: сюита остаётся зелёной, но кейс не забыт. А когда баг починят, pytest отметит его как XPASS — «падать перестало, пора снять пометку». Кейс сам напомнит о себе.
Выносим данные из кода
Когда наборов становится тридцать и их хочет править аналитик, держать их в Python-файле неудобно. Данные уезжают в JSON, а тест читает файл:
[
{"id": "pustaya-pochta", "email": "", "password": "S3cret-pass", "error": "Укажите почту"},
{"id": "korotkiy-parol", "email": "anna@example.com", "password": "123", "error": "Пароль короче 8 символов"}
]
# tests/test_registration_data_driven.py
import json
import pathlib
import pytest
from pages.register_page import RegisterPage
DATA = json.loads(
(pathlib.Path(__file__).parent / 'data' / 'invalid_registration.json').read_text(encoding='utf-8')
)
@pytest.mark.parametrize('case', DATA, ids=[c['id'] for c in DATA])
def test_nevalidnaya_registraciya(driver, case):
page = RegisterPage(driver).open().submit(case['email'], case['password'])
assert page.error_text == case['error']
Заметьте: файл читается на уровне модуля, а не внутри теста. Так и должно быть — pytest обязан узнать состав кейсов на этапе сбора тестов (collection), до того как хоть один браузер запустится.
Параметризовать можно и фикстуру
Иногда варьируются не данные, а окружение — например, надо прогнать всю сюиту в двух браузерах. Тогда параметр вешают на фикстуру, и все тесты, которые её просят, удваиваются автоматически:
# conftest.py
import pytest
from selenium import webdriver
@pytest.fixture(params=['chrome', 'firefox'], ids=['chrome', 'firefox'])
def driver(request):
if request.param == 'chrome':
driver = webdriver.Chrome()
else:
driver = webdriver.Firefox()
yield driver
driver.quit()
Ни один тест не меняется — но каждый теперь исполняется дважды: test_uspeshnyy_vhod[chrome] и test_uspeshnyy_vhod[firefox]. Внутри фикстуры текущее значение параметра лежит в request.param.
Как это работает
Параметризация раскрывается на этапе сбора тестов, ещё до запуска. pytest берёт функцию с шестью наборами и создаёт шесть независимых объектов-тестов — у каждого своё имя, свой результат, свой набор фикстур. Отсюда все свойства: кейсы изолированы (падение третьего не мешает четвёртому), их можно фильтровать по -k, раскидывать по воркерам pytest-xdist и перезапускать поштучно.
Именно поэтому в списке параметров нельзя вычислять что-то тяжёлое или обращаться к фикстурам: на момент сбора браузера ещё нет и быть не может. Всё динамическое (уникальная почта, созданный через API заказ) готовится внутри теста или в фикстуре, а параметризация подаёт только «плоские» данные.
Если декораторов parametrize два, pytest перемножает наборы. Два браузера на три роли — шесть тестов. Это мощно и это же главная ловушка: декартово произведение растёт быстрее, чем кажется, а каждый UI-кейс стоит секунд десять реального времени.
Частые ошибки
| Ошибка | Что происходит |
Цикл for по данным внутри одного теста | Первое падение обрывает проверку остальных наборов, отчёт бесполезен. |
Нет ids | Имена кейсов вида [anna@ example.com-S3cret-pass0], отчёт CI нечитаем. |
| Общие данные между кейсами | Регистрация на одну и ту же почту: первый кейс проходит, второй ловит «пользователь существует». Генерируйте уникальные значения: f'test-{uuid4().hex[:8]}@example.com'. |
| Обращение к фикстуре в списке параметров | На этапе сбора фикстур ещё нет — pytest упадёт. Динамику готовьте внутри теста. |
| Проверять через UI всю матрицу валидации | 50 браузерных кейсов ради проверки регулярки — это час прогона. По пирамиде тестирования 90% валидации проверяется юнит- и API-тестами, а через UI оставляют 2–3 представителя. |
| Смешивать позитив и негатив в одном списке | Разная логика проверки, приходится ветвить тест if-ами. Делайте два теста: test_uspeshnaya_registraciya и test_nevalidnaya_registraciya. |
Итоги
@pytest.mark.parametrizeпревращает один тест в N независимых кейсов ещё на этапе сбора.ids— не украшение: без них отчёт нечитаем, а перезапустить один кейс невозможно.pytest.param(..., marks=pytest.mark.xfail)позволяет не удалять кейс с известным багом.- Данные можно вынести в JSON или CSV — тест читает их на уровне модуля.
@pytest.fixture(params=[...])параметризует окружение (браузеры, роли) без правки тестов.- Не тащите всю матрицу валидации в UI: браузерных кейсов должно быть мало, они дорогие.