Параметризация и 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: браузерных кейсов должно быть мало, они дорогие.
Проверьте себя
1. Почему проверять набор невалидных значений циклом for внутри одного теста — плохая идея?
AЦиклы работают медленнее, чем parametrize
BПервый упавший assert прерывает цикл, остальные наборы не проверяются, а в отчёте один красный тест вместо детализации
Cpytest запрещает циклы в тестах
DВнутри цикла недоступна фикстура driver
2. Что делает параметр ids в @pytest.mark.parametrize?
AЗадаёт порядок выполнения кейсов
BПередаёт в тест дополнительные данные
CЗадаёт человекочитаемые имена кейсов в отчёте и позволяет запустить конкретный кейс по имени
DОтмечает кейсы, которые нужно пропустить