Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 8 из 20 Средний 30 мин 130 XP

Параметризация: @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
Пример настоящего отчёта pytest 9: коллекция падает с ошибкой и кодом выхода 2, до запуска тестов дело не доходит. Сообщение называет обе стороны: сколько имён и сколько значений.

Это дружелюбная ошибка: сообщение прямо говорит — имён 2, значений 3. Обычно виноват пропущенный элемент в одном из кортежей или лишнее имя в строке. Проверяется за секунды, зато экономит минуты недоумения вида «почему pytest вообще не находит мои тесты».

Один параметр — можно и без кортежей

Если параметр всего один, кортежи избыточны: список значений передаётся напрямую, а строка имён остаётся той же. Тест на ограничение длины слова:

parametrize с одним параметром
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"
Проверь себя
0 / 6

1. Что передаётся первым аргументом @pytest.mark.parametrize?

2. Сколько запусков даст parametrize со списком из пяти кортежей?

3. Один случай параметризованного теста упал. Что будет с остальными?

4. Что означает test_mul[2-3-7] в строке отчёта?

5. В строке имён два имени, а в каждом кортеже три значения. Что произойдёт?

6. Почему цикл for внутри теста — плохая замена параметризации?

Карточки терминов
Запомнено: 0 / 6
Практика

Параметризуй тест функции to_cents(rubles), которая переводит рубли в копейки умножением на 100. Три кейса: 1 рубль — 100 копеек, 0 рублей — 0 копеек, 2.5 рубля — 250 копеек. Запусти файл через pytest.main и напечатай код выхода.

practice.py
Вопросы и ответы по уроку

Как параметризовать тест в 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.