Skip to main content
Back to course
Pytest с нуля: Первые тесты за 2.5 часа
5 / 863%

Parametrize: один тест — десятки сценариев

25 минут

Представьте: нужно протестировать функцию validate_email() с 10 разными email'ами. Копировать тест 10 раз?

Цель: Научиться использовать @pytest.mark.parametrize для тестирования множества сценариев одним тестом.

Вы точно готовы?

Убедитесь, что понимаете:

# Простой тест
def test_addition():
    assert add(2, 3) == 5

Если базовые тесты непонятны — вернитесь к урокам 0-3.

Проблема: копипаста тестов

Плохой подход: 5 одинаковых тестов

# ❌ ПЛОХО — повторяем один и тот же тест
def test_add_positive_numbers():
    assert add(2, 3) == 5
 
def test_add_zeros():
    assert add(0, 0) == 0
 
def test_add_negative_numbers():
    assert add(-1, -2) == -3
 
def test_add_mixed_numbers():
    assert add(-1, 5) == 4
 
def test_add_large_numbers():
    assert add(1000, 2000) == 3000

Проблемы:

  • ❌ Много дублирования
  • ❌ Сложно добавить новый тест-кейс
  • ❌ Если логика теста меняется — нужно править 5 мест

Решение: @pytest.mark.parametrize

Базовый пример

# ✅ ХОРОШО — один тест, 5 сценариев
import pytest
 
@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, -2, -3),
    (-1, 5, 4),
    (1000, 2000, 3000),
])
def test_add(a, b, expected):
    """Тест сложения с разными числами"""
    result = add(a, b)
    assert result == expected

Что делает pytest:

  1. Читает список параметров [(2, 3, 5), (0, 0, 0), ...]
  2. Для КАЖДОГО набора запускает тест
  3. Если один тест падает — остальные всё равно запускаются

Запускаем:

pytest test_calculator.py::test_add -v

Результат:

test_add[2-3-5] PASSED                                               [ 20%]
test_add[0-0-0] PASSED                                               [ 40%]
test_add[-1--2--3] PASSED                                            [ 60%]
test_add[-1-5-4] PASSED                                              [ 80%]
test_add[1000-2000-3000] PASSED                                      [100%]
 
============================= 5 passed in 0.01s =============================

✅ Один тест превратился в 5 отдельных!

Синтаксис parametrize

@pytest.mark.parametrize("параметры", [
    (значение1_для_параметра1, значение1_для_параметра2, ...),
    (значение2_для_параметра1, значение2_для_параметра2, ...),
])
def test_функция(параметр1, параметр2, ...):
    # Тест с параметрами
    pass

Пример с одним параметром:

@pytest.mark.parametrize("number", [1, 2, 3, 4, 5])
def test_is_positive(number):
    """Все числа положительные"""
    assert number > 0

Пример с тремя параметрами:

@pytest.mark.parametrize("username, password, expected_success", [
    ("admin", "secret123", True),
    ("user", "wrongpass", False),
    ("", "nouser", False),
])
def test_login(username, password, expected_success):
    result = login(username, password)
    assert result.success == expected_success

Понятные названия: ids parameter

Проблема: нечитаемые названия

По умолчанию pytest генерирует названия из значений:

test_validate_email[test@example.com] PASSED
test_validate_email[invalid.com] FAILED
test_validate_email[@nodomain] FAILED

Для простых значений — ОК. Но для сложных объектов:

test_user[<User object at 0x7f...>] PASSED  # Непонятно!

Решение: ids для читаемости

import pytest
 
@pytest.mark.parametrize("email, is_valid", [
    ("test@example.com", True),
    ("user.name@domain.co.uk", True),
    ("invalid.com", False),
    ("@nodomain", False),
    ("no-at-sign.com", False),
], ids=[
    "valid-simple",
    "valid-subdomain",
    "missing-at",
    "missing-username",
    "missing-domain",
])
def test_validate_email(email, is_valid):
    """Валидация email'ов"""
    result = validate_email(email)
    assert result == is_valid

Результат:

test_validate_email[valid-simple] PASSED                            [ 20%]
test_validate_email[valid-subdomain] PASSED                         [ 40%]
test_validate_email[missing-at] FAILED                              [ 60%]
test_validate_email[missing-username] FAILED                        [ 80%]
test_validate_email[missing-domain] FAILED                          [100%]

✅ Теперь понятно какой тест-кейс упал!

ids как функция (для сложных случаев)

def idfn(test_input):
    """Генерирует id из входных данных"""
    if test_input.startswith("valid"):
        return f"valid-case-{test_input[:10]}"
    return f"invalid-case-{test_input[:10]}"
 
@pytest.mark.parametrize("email", [
    "valid@example.com",
    "invalid.com",
], ids=idfn)
def test_email(email):
    pass

Практический пример: BankAccount

Тестируем множество сценариев

# src/bank_account.py уже создан в уроке 2
 
import pytest
from src.bank_account import BankAccount
 
@pytest.mark.parametrize("initial, deposit, expected", [
    (100, 50, 150),
    (0, 100, 100),
    (1000, 1, 1001),
    (50, 50, 100),
], ids=[
    "normal-deposit",
    "deposit-to-empty",
    "small-deposit-to-large",
    "equal-amounts",
])
def test_deposit_scenarios(initial, deposit, expected):
    """Различные сценарии пополнения"""
    # Arrange
    account = BankAccount(initial)
 
    # Act
    new_balance = account.deposit(deposit)
 
    # Assert
    assert new_balance == expected, (
        f"Deposit {deposit} to {initial} should give {expected}, "
        f"got {new_balance}"
    )
 
 
@pytest.mark.parametrize("initial, withdraw, remaining", [
    (100, 50, 50),
    (100, 100, 0),
    (1000, 1, 999),
], ids=[
    "partial-withdraw",
    "full-withdraw",
    "tiny-withdraw",
])
def test_withdraw_scenarios(initial, withdraw, remaining):
    """Различные сценарии снятия"""
    # Arrange
    account = BankAccount(initial)
 
    # Act
    new_balance = account.withdraw(withdraw)
 
    # Assert
    assert new_balance == remaining
    assert account.balance == remaining

Тестируем ошибки

@pytest.mark.parametrize("initial, amount, error_msg", [
    (100, -50, "must be positive"),
    (100, 0, "must be positive"),
], ids=[
    "negative-deposit",
    "zero-deposit",
])
def test_invalid_deposit(initial, amount, error_msg):
    """Невалидные пополнения должны выбрасывать ошибку"""
    account = BankAccount(initial)
 
    with pytest.raises(ValueError) as exc_info:
        account.deposit(amount)
 
    assert error_msg in str(exc_info.value)
 
 
@pytest.mark.parametrize("initial, withdraw", [
    (100, 150),
    (0, 1),
    (50, 51),
], ids=[
    "overdraft-50",
    "withdraw-from-empty",
    "overdraft-by-1",
])
def test_insufficient_funds(initial, withdraw):
    """Попытка снять больше чем есть"""
    account = BankAccount(initial)
 
    with pytest.raises(ValueError) as exc_info:
        account.withdraw(withdraw)
 
    assert "Insufficient funds" in str(exc_info.value)

Множественная параметризация

Комбинации параметров

Можно применить @pytest.mark.parametrize ДВАЖДЫ:

@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_combinations(x, y):
    """Тестирует все комбинации x и y"""
    print(f"Testing x={x}, y={y}")
    assert x * y >= 0

Результат: 4 теста (2 значения x × 2 значения y)

test_combinations[2-0] PASSED  # x=0, y=2
test_combinations[2-1] PASSED  # x=1, y=2
test_combinations[3-0] PASSED  # x=0, y=3
test_combinations[3-1] PASSED  # x=1, y=3

Практический пример: матричное тестирование

@pytest.mark.parametrize("user_type", ["guest", "user", "admin"])
@pytest.mark.parametrize("resource", ["profile", "settings", "admin-panel"])
def test_access_control(user_type, resource):
    """Проверяем права доступа для всех комбинаций"""
    can_access = check_access(user_type, resource)
 
    # admin может всё
    if user_type == "admin":
        assert can_access == True
 
    # guest не может ничего
    elif user_type == "guest":
        assert can_access == False
 
    # user может profile и settings, но не admin-panel
    elif user_type == "user":
        if resource == "admin-panel":
            assert can_access == False
        else:
            assert can_access == True

Запустится: 9 тестов (3 user_type × 3 resource)

Когда НЕ использовать parametrize

❌ Плохие случаи использования

# ❌ ПЛОХО — тесты проверяют РАЗНЫЕ концепции
@pytest.mark.parametrize("operation", [
    "deposit",
    "withdraw",
    "get_balance",
])
def test_bank_operations(operation):
    # Слишком разная логика для каждого случая
    pass
# ❌ ПЛОХО — только 1-2 сценария
@pytest.mark.parametrize("value", [5])  # Зачем parametrize?
def test_single_value(value):
    assert value == 5

✅ Хорошие случаи использования

  • ✅ Тестируете ОДНУ функцию с разными входными данными
  • ✅ Много похожих тест-кейсов (> 3)
  • ✅ Легко добавить новый сценарий (одна строка)
  • ✅ Все сценарии проверяют ОДНО поведение

Параметры как словарь

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

import pytest
 
test_cases = {
    "valid_simple": {
        "email": "test@example.com",
        "is_valid": True,
    },
    "invalid_no_at": {
        "email": "invalid.com",
        "is_valid": False,
    },
}
 
# Явно извлекаем параметры в нужном порядке
@pytest.mark.parametrize(
    "email, is_valid",
    [(case["email"], case["is_valid"]) for case in test_cases.values()],
    ids=list(test_cases.keys()),
)
def test_validate_email(email, is_valid):
    result = validate_email(email)
    assert result == is_valid

Что вы изучили

  • @pytest.mark.parametrize — один тест, много сценариев
  • ids parameter — понятные названия тестов
  • Множественная параметризация — матричное тестирование
  • Когда использовать — похожие тест-кейсы
  • Когда НЕ использовать — разная логика, мало кейсов

Следующий урок

Отлично! Теперь вы умеете запускать один тест с разными данными. Но что если у вас 100 тестов, и вы хотите запустить только быстрые или только медленные?

Переходите к уроку 5: Маркеры: запускаем нужные тесты

В следующем уроке вы узнаете:

  • @pytest.mark.slow для медленных тестов
  • pytest -m slow — выборочный запуск
  • Регистрация маркеров в pytest.ini
  • Группировка тестов по типам

Устранение неисправностей

@pytest.mark.parametrize должен стоять перед функцией, а сигнатура теста должна совпадать с объявленными параметрами

Проверьте:

  • Декоратор @pytest.mark.parametrize указан над тестом
  • Строка параметров содержит имена: "a, b, expected"
  • Функция принимает те же аргументы: def test_add(a, b, expected)

Имя и количество аргументов в тесте должны совпадать с параметрами декоратора

Проверьте:

  • @pytest.mark.parametrize перечисляет те же имена, что и сигнатура теста
  • Сигнатура выглядит как def test_add(a, b, expected):

Проверьте соответствие количества ids и тест-кейсов

Проверьте:

  • Количество элементов в ids равно количеству наборов данных
  • ids — список строк, например ids=["case-1", "case-2"]
  • Аргумент называется ids= (не id=)
Parametrize: один тест — десятки сценариев — Pytest с нуля: Первые тесты за 2.5 часа — Potapov.me