Параметризация: @pytest.mark.parametrize
Десять одинаковых тестов для десяти случаев — это десять копипаст. parametrize сжимает их в один тест и список кортежей, а pytest разворачивает обратно в десять отчётных строк.
Редакция Питоники
Знакомая картина: функция написана, и надо проверить её на трёх-пяти входах. Копируешь тест, меняешь числа, копируешь ещё раз. Через неделю случаев пятнадцать, файл тестов — простыня, а правка сигнатуры функции превращает день в каторгу: одну и ту же проверку приходится чинить в пятнадцати местах. Вся эта копипаста лечится одним декоратором — параметризацией.
Идея простая до безобразия: тест остаётся один, а входные данные переезжают в список. pytest читает список и запускает тест отдельно для каждой строки — со своим именем, своим отчётом и своей судьбой: один случай может упасть, не задевая соседних. К концу урока ты будешь писать таблицы тестов, которые читаются как спецификация.
Десять копий одного теста: как не надо
Посмотри, как выглядит «лобовое» решение — три случая умножения, три теста, отличающиеся только числами:
from pathlib import Path
Path("test_mul_copy.py").write_text("""
def mul(a, b):
return a * b
def test_mul_two_three():
assert mul(2, 3) == 6
def test_mul_zero_five():
assert mul(0, 5) == 0
def test_mul_minus_two_four():
assert mul(-2, 4) == -8
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_mul_copy.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.02s exit: 0
Работает — и уже на трёх случаях видно, к чему это приведёт: имя каждого теста выдумывается руками, логика продублирована трижды, а новый случай — это новая порция копипасты вместо новой строчки данных. В четвёртом уроке мы уже договаривались: тесты не зависят друг от друга, и это хорошо, — но дублировать сам проверочный код тесты не обязаны. Для одинаковой логики с разными данными у pytest есть штатное средство.
parametrize: один тест и список кейсов
Декоратор @pytest.mark.parametrize принимает два аргумента: строку с именами параметров и список кортежей. Имена из строки становятся аргументами тестовой функции, каждый кортеж — значениями для одного запуска:
from pathlib import Path
Path("test_mul.py").write_text("""
import pytest
def mul(a, b):
return a * b
@pytest.mark.parametrize("a, b, res", [
(2, 3, 6),
(0, 5, 0),
(-2, 4, -8),
])
def test_mul(a, b, res):
assert mul(a, b) == res
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_mul.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.01s exit: 0
Три точки в отчёте — три запуска. Разбираем анатомию по кусочкам.
| Часть | Пример | Что делает |
|---|---|---|
| строка имён | "a, b, res" | имена через запятую; столько же, сколько элементов в каждом кортеже |
| список кейсов | [(2, 3, 6), (0, 5, 0)] | один кортеж — один запуск теста |
| аргументы функции | def test_mul(a, b, res) | те же имена, pytest сам подставит значения |
| id случая | test_mul[2-3-6] | имя теста плюс значения кортежа — видно в отчёте |
Читается такая таблица как спецификация: «умножение двух на три — шесть, нуля на пять — ноль, минус двух на четыре — минус восемь». Добавить четвёртый случай — значит дописать один кортеж, а не копировать функцию. Порядок в кортеже соответствует порядку имён в строке: перепутаешь — тест честно упадёт и покажет, где именно разъехалось.
Запусти тот же файл в подробном режиме -v из урока 3 — и увидишь, что каждый случай стал самостоятельным тестом со своим именем:
from pathlib import Path
Path("test_mul.py").write_text("""
import pytest
def mul(a, b):
return a * b
@pytest.mark.parametrize("a, b, res", [
(2, 3, 6),
(0, 5, 0),
(-2, 4, -8),
])
def test_mul(a, b, res):
assert mul(a, b) == res
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_mul.py", "-v", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
============================= test session starts ============================= collecting ... collected 3 items test_mul.py::test_mul[2-3-6] PASSED [ 33%] test_mul.py::test_mul[0-5-0] PASSED [ 66%] test_mul.py::test_mul[-2-4--8] PASSED [100%] ============================== 3 passed in 0.01s ============================== exit: 0
Квадратные скобки после имени теста — это и есть значения кортежа: test_mul[2-3-6] — случай с a=2, b=3, res=6. Именно по этой подписи в отчёте ты мгновенно видишь, какой именно вход сломался, — без -v это работает и в краткой строке результатов: каждая точка или буква F соответствует одному случаю.
Что можно класть в кейсы? Всё, что живёт в переменной: числа, строки, логические значения, списки и даже словари — parametrize безразличен к типу, он просто передаёт значения в аргументы. Ограничение одно: каждый кортеж — это значения для одного вызова, а не «полстраницы логики». Если в кейсе хочется выполнить три действия подряд — это сигнал, что проверку пора разложить на несколько тестов или вынести подготовку в фикстуру, а в таблице оставить только вход и ожидаемый результат.
Падение одного случая не роняет остальные
Главное свойство параметризации проявляется, когда данные расходятся с кодом. Подсунем в таблицу плохой кортеж — тот, где 2 * 3 не равно 7, — и посмотрим, что произойдёт с остальными:
from pathlib import Path
Path("test_mul.py").write_text("""
import pytest
def mul(a, b):
return a * b
@pytest.mark.parametrize("a, b, res", [
(2, 3, 6),
(2, 3, 7),
(0, 5, 0),
(-2, 4, -8),
])
def test_mul(a, b, res):
assert mul(a, b) == res
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_mul.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.F.. [100%] =========================== short test summary info =========================== FAILED test_mul.py::test_mul[2-3-7] - assert 6 == 7 1 failed, 3 passed in 0.01s exit: 1
Читай строку результатов как маршрут: точка — первый случай прошёл, F — второй упал, и ещё две точки — третий и четвёртый выполнены, несмотря на аварию посередине. Сводка называет виновника по имени: test_mul[2-3-7]. Код выхода 1 — набор в целом красный, но три случая из четырёх доказали свою работоспособность.
Сравни с копипаст-вариантом: там упавший тест просто выпал бы из зелёного списка, и ты бы узнал об этом по общему числу. Здесь же таблица сама показывает, какая строка данных врёт. Это меняет саму механику отладки: ты правишь не тест, а одну строку данных — или одну строчку кода, если врёт она.
Число имён и длина кортежа обязаны совпадать
У параметризации есть один формальный контракт: в строке "a, b, res" — три имени, значит в каждом кортеже — три значения. Напишешь два имени и кортеж из трёх чисел — pytest остановится ещё до запуска тестов, при сборке:
import pytest
@pytest.mark.parametrize("a, b", [
(2, 3, 6),
])
def test_mul(a, b):
assert a * b == 6
=================================== ERRORS ==================================== ________________________ ERROR collecting test_bad.py _________________________ test_bad.py::test_mul: in "parametrize" the number of names (2): ['a', 'b'] must be equal to the number of values (3): (2, 3, 6) =========================== short test summary info =========================== ERROR test_bad.py - Failed: test_bad.py::test_mul: in "parametrize" the numbe... !!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! 1 error in 0.11s exit: 2
Это дружелюбная ошибка: сообщение прямо говорит — имён 2, значений 3. Обычно виноват пропущенный элемент в одном из кортежей или лишнее имя в строке. Проверяется за секунды, зато экономит минуты недоумения вида «почему pytest вообще не находит мои тесты».
Один параметр — можно и без кортежей
Если параметр всего один, кортежи избыточны: список значений передаётся напрямую, а строка имён остаётся той же. Тест на ограничение длины слова:
from pathlib import Path
Path("test_words.py").write_text("""
import pytest
@pytest.mark.parametrize("word", ["", "a", "ab"])
def test_len_fits_limit(word):
assert len(word) <= 2
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_words.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.01s exit: 0
Значения здесь не кортежи, а обычные строки — по одному на запуск. Как только параметров становится два и больше, возвращайся к кортежам: пары вида ("anna@mail.ru", True) из финального примера ниже — правильная форма для двух параметров. Кстати, замечание о читаемости: пустая строка "" в кейсах — это тоже полноценный граничный случай, и параметризация — самый дешёвый способ держать такие границы под присмотром. Про границы и подписи к ним подробнее поговорим в уроке 14.
Первая программа: таблица проверки email
Соберём всё в законченный пример. Валидация — идеальный заказчик параметризации: правило одно, а входов много, включая заведомо битые. Каждая строка таблицы — вход и ожидаемый вердикт:
from pathlib import Path
Path("test_email.py").write_text("""
import pytest
def is_valid_email(text):
return text.count("@") == 1 and "." in text.split("@")[1]
@pytest.mark.parametrize("text, ok", [
("anna@mail.ru", True),
("anna.mail.ru", False),
("anna@mail", False),
("", False),
])
def test_is_valid_email(text, ok):
assert is_valid_email(text) == ok
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_email.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.... [100%] 4 passed in 0.01s exit: 0
Обрати внимание на второй и третий кортежи: ожидание здесь False, то есть тест проверяет, что функция отвергнет адрес. Таблица описывает и то, что должно работать, и то, что обязано ломаться, — в одном списке. Именно такие таблицы потом превращаются в документацию функции: новый коллега читает кейсы и за минуту понимает правила валидации лучше, чем из трёх абзацев описания.
Практический масштаб: в настоящих проектах параметризованные таблицы живут сотнями строк, и именно они дают ту уверенность, ради которой тесты вообще пишут. Функция изменилась — прогон таблицы за секунды показывает, какие правила сломались. Чем раньше начнёшь мыслить кейсами, тем дешевле будет каждый следующий рефакторинг: добавление случая стоит одной строки данных, а не нового файла теста.
Что дальше
Ты сжал десяток копипаст-тестов в один и список кортежей: строка имён, таблица кейсов, отчёт по каждому запуску, живучесть при падении одного случая. Дальше — логичный спутник таблиц: pytest.raises — проверки того, что функция на плохом входе бросает исключение, и тоже разворачиваемые в параметризованные таблицы. А фикстуры из урока 5 прекрасно сочетаются с параметризацией: данные готовит фикстура, случаи задаёт таблица.
Параметризация — это таблица вместо копипасты: одна строка списка — один случай, один символ отчёта, одна причина упасть.
Сначала предскажи ответ в голове — это главный навык программиста.
cases = [(2, 3, 6), (1, 1, 2), (0, 9, 0)]
for a, b, res in cases:
assert a * b == res
print("ok:", a, "*", b)
print("всего кейсов:", len(cases))
import pytest
@pytest.mark.parametrize("word", ["a", "bb", "ccc"])
def test_not_empty(word):
assert word != ""
# запустили файл с "-q" и "--no-header"
1. Что передаётся первым аргументом @pytest.mark.parametrize?
2. Сколько запусков даст parametrize со списком из пяти кортежей?
3. Один случай параметризованного теста упал. Что будет с остальными?
4. Что означает test_mul[2-3-7] в строке отчёта?
5. В строке имён два имени, а в каждом кортеже три значения. Что произойдёт?
6. Почему цикл for внутри теста — плохая замена параметризации?
Параметризуй тест функции to_cents(rubles), которая переводит рубли в копейки умножением на 100. Три кейса: 1 рубль — 100 копеек, 0 рублей — 0 копеек, 2.5 рубля — 250 копеек. Запусти файл через pytest.main и напечатай код выхода.
Как параметризовать тест в pytest?
Поставь над тестовой функцией @pytest.mark.parametrize: первым аргументом — строку с именами параметров ("a, b, res"), вторым — список кортежей, по одному на случай. pytest запустит тест отдельно для каждого кортежа, каждый случай получит свой id в отчёте и будет выполняться независимо от соседних.
Почему в отчёте pytest пишет test_mul[2-3-7]?
Квадратные скобки — это id случая: pytest собирает его из имени теста и значений кортежа. По такой подписи сразу видно, на каком именно входе упал тест, без открытия файла. Значения разделяются дефисами, а не запятыми — запятая конфликтовала бы с разделителем параметров.
Один кейс параметризованного теста упал — выполняются ли остальные?
Да: каждый кортеж — самостоятельный тест. Упавший помечается буквой F на своём месте в строке результатов, остальные выполняются, а в сводке видно точное соотношение: 1 failed, 3 passed. Поэтому падение одного случая — повод исправить одну строку данных, а не перечитывать весь набор.
Что делать, если pytest ругается на число имён и значений в parametrize?
Ошибка «the number of names (2) must be equal to the number of values (3)» означает несовпадение: в строке имён одно количество, в кортеже — другое. Проверь каждый кортеж на пропущенный элемент и убери лишние имена из строки. Ошибка возникает при сборке, до запуска тестов, поэтому чинится за секунды.
Можно ли параметризовать тест с одним параметром без кортежей?
Да: если параметр один, передай список обычных значений — @pytest.mark.parametrize("word", ["", "a", "ab"]). Каждый элемент списка станет отдельным запуском. Как только параметров два и больше, значения оборачиваются в кортежи, по одному на случай.
Понравился урок? Сошлитесь на него
«Сводка называет виновника по имени: test_mul[2-3-7].»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 5
Фикстуры pytest: @pytest.fixture для подготовки данных
Одна и та же корзина собирается в каждом тесте заново — знакомая копипаста. Фикстура описывает подготовку один раз, а pytest сам передаёт результат в тест по имени.
pytest · Урок 9
Проверка исключений: pytest.raises
Хорошая функция на плохом входе не молчит, а бросает исключение — и тест обязан это проверять. pytest.raises делает это одной строкой внутри with.
pytest · Урок 14
Параметризация продвинутая: ids и несколько наборов
Таблице кейсов не хватает двух вещей: имён вместо безликих чисел в отчёте и комбинаций нескольких наборов. Оба закрываются аргументом ids= и стекированием декораторов.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestpytest fixture test_client
pytest · Урок 2
Как pytest находит тесты: конвенции имён файлов и функций
pytest не читает мысли: тестом становится только то, что названо по конвенции — test_*.py, test_-функции, классы Test*. И адрес одного теста: файл::тест.
pytest как запускать тестыpytest конвенции имён