Parametrize: один тест — десятки сценариев
Представьте: нужно протестировать функцию 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:
- Читает список параметров
[(2, 3, 5), (0, 0, 0), ...] - Для КАЖДОГО набора запускает тест
- Если один тест падает — остальные всё равно запускаются
Запускаем:
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=)