Параметризация продвинутая: ids и несколько наборов
Таблице кейсов не хватает двух вещей: имён вместо безликих чисел в отчёте и комбинаций нескольких наборов. Оба закрываются аргументом ids= и стекированием декораторов.
Редакция Питоники
В уроке 8 ты собрал первую таблицу кейсов и уже видел её слабое место: в отчёте случай подписан значениями — test_mul[2-3-7]. Для чисел это сносно, но как только кейсы становятся сложнее, подпись превращается в кашу, а для строк и вовсе в escape-кракозябры. Пора дать кейсам имена и научиться комбинировать наборы — это последние два приёма параметризации, после которых таблицы можно писать для любой функции.
Маршрут урока: сначала подписываем кейсы через ids — руками и функцией; затем стекируем наборы и смотрим, как склеиваются их подписи; в конце заводим обязательную таблицу краёв и учимся запускать один кейс по имени. Всё это — надстройка над базой из урока 8, так что таблицы писать будет уже привычно.
ids: имена кейсов в отчёте
Аргумент ids принимает список подписей — по одной на кортеж. pytest подставит их в имена тестов вместо значений:
from pathlib import Path
Path("test_discount.py").write_text("""
import pytest
def discount(price, percent):
return price * (100 - percent) / 100
@pytest.mark.parametrize(
"price, percent, result",
[
(1000, 10, 900.0),
(1000, 0, 1000.0),
(500, 100, 0.0),
],
ids=["ten-percent", "no-discount", "free"],
)
def test_discount(price, percent, result):
assert discount(price, percent) == result
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_discount.py", "-v", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
============================= test session starts ============================= collecting ... collected 3 items test_discount.py::test_discount[ten-percent] PASSED [ 33%] test_discount.py::test_discount[no-discount] PASSED [ 66%] test_discount.py::test_discount[free] PASSED [100%] ============================== 3 passed in 0.02s ============================== exit: 0
Вместо test_discount[1000-10-900.0] отчёт пишет test_discount[ten-percent] — история кейса читается без расшифровки чисел. Имя кейса — это вывод отладки, который ты написал заранее: отчёт называет случай человеческим голосом. В длинных таблицах — двадцать, тридцать кейсов — имена экономят минуты на каждом прогоне с падением.
Сила имён проявляется в беде. Подсунем в таблицу неверное ожидание — и посмотрим, как отчёт называет виновника:
from pathlib import Path
Path("test_discount.py").write_text("""
import pytest
def discount(price, percent):
return price * (100 - percent) / 100
@pytest.mark.parametrize(
"price, percent, result",
[
(1000, 10, 900.0),
(2000, 25, 1400.0),
(500, 100, 0.0),
],
ids=["ten-percent", "quarter", "free"],
)
def test_discount(price, percent, result):
assert discount(price, percent) == result
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_discount.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.F. [100%] =========================== short test summary info =========================== FAILED test_discount.py::test_discount[quarter] - assert 1500.0 == 1400.0 1 failed, 2 passed in 0.01s exit: 1
FAILED test_discount[quarter] — даже в кратком отчёте видно: сломался кейс со скидкой в четверть, а не десять процентов и не бесплатный товар. Числовая подпись заставила бы сверять значения; имя исключает сверку. Заметь и честность самой таблицы: подпись quarter не соврала — 25 процентов это и есть четверть.
test_cyr.py::test_case[\u043e\u0431\u044b\u0447\u043d\u0430\u044f \u0441\u043a\u0438\u0434\u043a\u0430] PASSED [ 50%]
test_cyr.py::test_case[\u0432\u0441\u0451 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e] PASSED [100%]
Собирательное правило хорошего имени: прочёл в отчёте — понял случай без открытия файла. Практика, которая работает:
- короткое имя роли случая: ten-percent, empty-input, negative-price;
- латиница и дефисы — пробелы и кириллица в отчёте выглядят плохо;
- имя не повторяет значения, а называет их смысл;
- подписи идут в том же порядке, что и кортежи, — держи их рядом в коде.
Стекирование: два набора — все комбинации
Второй приём — стекирование: несколько декораторов parametrize над одним тестом. pytest берёт декартово произведение: каждый кейс первого набора прогоняется с каждым кейсом второго. Проверим функцию clamp, которая зажимает значение в диапазон, на трёх значениях и двух диапазонах:
from pathlib import Path
Path("test_clamp.py").write_text("""
import pytest
def clamp(value, low, high):
return max(low, min(value, high))
@pytest.mark.parametrize("value", [-5, 5, 50])
@pytest.mark.parametrize("low, high", [(0, 10), (20, 30)])
def test_clamp(value, low, high):
result = clamp(value, low, high)
assert low <= result <= high
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_clamp.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
...... [100%] 6 passed in 0.01s exit: 0
Три значения на два диапазона — шесть запусков, по символу на каждый. Свойство инвариантности здесь то, что и нужно: при любом входе результат обязан остаться внутри диапазона — шесть комбинаций проверяют это свойство со всех сторон. Так стекирование экономит таблицы: вместо шести кортежей из четырёх полей — два коротких набора.
Порядок декораторов влияет на порядок запусков, но не на состав: нижний декоратор меняется быстрее, как внутренний цикл. В отчёте -v это видно по подписям: значения верхнего набора идут внешним слоем. Для большинства тестов порядок не важен — важно, что ни одна комбинация не потерялась.
from pathlib import Path
Path("test_clamp.py").write_text("""
import pytest
def clamp(value, low, high):
return max(low, min(value, high))
@pytest.mark.parametrize("value", [-5, 50], ids=["neg", "big"])
@pytest.mark.parametrize("low, high", [(0, 10)], ids=["tight"])
def test_clamp(value, low, high):
result = clamp(value, low, high)
assert low <= result <= high
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_clamp.py", "-v", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
============================= test session starts ============================= collecting ... collected 2 items test_clamp.py::test_clamp[tight-neg] PASSED [ 50%] test_clamp.py::test_clamp[tight-big] PASSED [100%] ============================== 2 passed in 0.01s ============================== exit: 0
Декораторов может быть и три, и четыре — произведение умножается дальше: три набора по три значения дадут уже 27 запусков. Помни об экспоненте: стекирование обманчиво дёшево на бумаге и дорого в CI. Практический потолок — пара десятков запусков на тест; дальше комбинируй не всё подряд, а осмысленные пары наборов.
Подписи из ids при стекировании склеиваются через дефис, причём первой идёт подпись нижнего декоратора — того самого «внутреннего цикла»: test_clamp[tight-neg], test_clamp[tight-big]. Двух слов на комбинацию хватает: из имени сразу ясно, какой диапазон и какое значение сошлись в этом запуске.
Писать подписи руками для длинных таблиц утомительно, поэтому ids принимает и функцию: ей передаётся значение параметра, а она возвращает строку-подпись. Удобно строить имена из самого значения:
from pathlib import Path
Path("test_words.py").write_text("""
import pytest
@pytest.mark.parametrize(
"word",
["", "a", "abc"],
ids=lambda w: "len-" + str(len(w)),
)
def test_words_are_short(word):
assert len(word) <= 3
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_words.py", "-v", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
============================= test session starts ============================= collecting ... collected 3 items test_words.py::test_words_are_short[len-0] PASSED [ 33%] test_words.py::test_words_are_short[len-1] PASSED [ 66%] test_words.py::test_words_are_short[len-3] PASSED [100%] ============================== 3 passed in 0.01s ============================== exit: 0
Справедливости ради: для простых значений pytest и сам неплох — числа, строки без не-ASCII, булевы значения и None превращаются в приличные подписи автоматически. Свои ids нужны там, где авто-подпись длинна (кортежи чисел), непонятна (объекты) или нечитаема (кириллица). Правило экономное: начни без ids, добавь имена, когда отчёт станет трудно читать.
Функция получает значение параметра и возвращает строку; для набора из нескольких параметров это кортеж целиком, и имя собирается из его частей. Так имена остаются синхронными с данными: добавил кейс — подпись появилась сама. Ручной список ids лучше там, где подпись важнее значения, функция — там, где кейсов много.
Граничные значения и плохие входы — отдельным набором
Третий приём — композиция с pytest.raises: нормальные случаи живут в одном наборе, а граничные и «плохие» — в другом, с проверкой отказа. Функция safe_div делит и отказывается делить на ноль:
from pathlib import Path
Path("test_div.py").write_text("""
import pytest
def safe_div(a, b):
if b == 0:
raise ValueError("делить на ноль нельзя")
return a / b
@pytest.mark.parametrize("b", [1, 2, -5])
def test_normal_division(b):
assert safe_div(10, b) == 10 / b
@pytest.mark.parametrize("b", [0, 0.0])
def test_zero_divisor_raises(b):
with pytest.raises(ValueError):
safe_div(10, b)
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_div.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
..... [100%] 5 passed in 0.01s exit: 0
Пять символов — три деления в норме и два отказа на границе. Ноль здесь не случайность, а дизайнерское решение: 0 и 0.0 — одно число с двух сторон, int и float, и оба обязаны упереться в отказ. Такие двойные границы ловят ошибки типов: функция, сравнивающая вход через == против числа, может пропустить один из вариантов — а таблица краёв заметит это за один прогон.
Для строковых функций таблица краёв своя: пустая строка, один символ, строка из пробелов, юникод с ударениями и эмодзи вместо букв. Механика та же: отдельный набор параметров, отдельные ожидания. Функции, которые наивно режут по первому символу или считают байты вместо символов, ловятся именно такими кейсами — не случайно они первые в чек-листах ревью.
| Приём | Как выглядит | Что даёт |
|---|---|---|
| ids | parametrize(..., ids=["ten-percent"]) | человеческие имена кейсов в отчёте |
| стекирование | два @parametrize над одним тестом | декартово произведение наборов |
| отдельный набор краёв | плохие входы + pytest.raises | границы и отказы без смешивания с нормой |
| ids по умолчанию | подпись из значений: [1000-10-900.0] | работает, но читается хуже |
Имя кейса путешествует дальше локальной консоли: в отчётах CI, в скриншотах падений, в ссылках из багтрекера. Строка FAILED test_discount[quarter] в чате команды самодостаточна — не нужно открывать файл, чтобы понять, о каком случае речь. В этом и смысл имён: они делают отчёты переносимыми между людьми и системами.
Именованные кейсы работают и как адрес для точечного запуска: флаг -k отбирает тесты по подстроке имени. Из таблицы скидок можно взять один кейс и погонять только его, пока чинишь:
from pathlib import Path
Path("test_discount.py").write_text("""
import pytest
def discount(price, percent):
return price * (100 - percent) / 100
@pytest.mark.parametrize(
"price, percent, result",
[
(1000, 10, 900.0),
(1000, 0, 1000.0),
(500, 100, 0.0),
],
ids=["ten-percent", "no-discount", "free"],
)
def test_discount(price, percent, result):
assert discount(price, percent) == result
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_discount.py", "-k", "free", "-v", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
============================= test session starts ============================= collecting ... collected 3 items / 2 deselected / 1 selected test_discount.py::test_discount[free] PASSED [100%] ======================= 1 passed, 2 deselected in 0.01s ======================= exit: 0
Отбор по -k чувствителен к регистру и ищет подстроку в имени: free найдёт и test_discount[free], и любой другой тест с free в имени. Для точного адреса случая в больших наборах комбинация -k и имён кейсов работает быстрее, чем правка файла: починил — прогнал один кейс — снял флаг и погнал всю таблицу.
Что дальше
Ты закончил арсенал параметризации: именованные кейсы через ids, стекирование наборов в декартово произведение и раздельные таблицы для нормы и краёв. Таблицы стали читаемыми, отчёты — говорящими, а граничные случаи — обязательной частью каждой таблицы. Дальше — conftest.py: общие фикстуры и регистрация меток для всей папки тестов, без импортов и повторов. Потом — тестирование классов и файловые тесты на JSON, где параметризованные таблицы встретятся с tmp_path.
Хорошая таблица кейсов читается как спецификация: имя говорит, что проверяем, набор краёв — где функция обязана упереться.
Сначала предскажи ответ в голове — это главный навык программиста.
values = [-5, 5, 50]
ranges = [(0, 10), (20, 30)]
total = len(values) * len(ranges)
checked = 0
for v in values:
for low, high in ranges:
assert low <= max(low, min(v, high)) <= high
checked += 1
print(total, checked)
cases = [
("empty", ""),
("one", "a"),
]
for name, text in cases:
size = len(text)
print(name, size)
1. Что задаёт аргумент ids= в @pytest.mark.parametrize?
2. Два декоратора parametrize над одним тестом дают сколько запусков?
3. Почему плохие входы выносят в отдельный набор параметров?
4. Почему подпись «без скидки» плохой вариант для ids?
5. Какие значения стоит включать в таблицу краёв?
6. Влияет ли порядок декораторов parametrize на состав комбинаций?
Параметризуй тест функции to_upper(text): три кейса — ("hello", "HELLO"), ("", "") и ("a-b", "A-B") — с подписями ids: simple, empty, dash. Запусти файл через pytest.main с флагом -v и напечатай код выхода, чтобы видеть имена кейсов в отчёте.
Как дать кейсам параметризованного теста читаемые имена в pytest?
Аргумент ids= у @pytest.mark.parametrize: передай список подписей по одной на кортеж, и pytest подставит их в имена тестов — test_discount[ten-percent] вместо test_discount[1000-10-900.0]. Пиши подписи латиницей и дефисами: кириллица экранируется в escape-последовательности и в отчёте превращается в нечитаемый набор.
Как прогнать все комбинации двух наборов параметров в pytest?
Стекированием: поставь два декоратора @pytest.mark.parametrize над одним тестом. pytest возьмёт декартово произведение — каждый кейс первого набора с каждым кейсом второго: три значения на два диапазона дадут шесть запусков. В отчёте каждая комбинация видна отдельной строкой.
Как параметризовать тесты на исключения в pytest?
Плохие входы собери в отдельный набор и оборачивай вызов в with pytest.raises: @pytest.mark.parametrize("bad", [0, -1, "три"]) с телом, ожидающим ValueError. Нормальные случаи держи в другой таблице с обычными assert — так каждая таблица остаётся однородной и читается как спецификация.
Почему кириллицу нельзя использовать в ids параметризации?
Идентификаторы кейсов pytest делает безопасными для файловых путей и консолей: не-ASCII символы экранируются в escape-последовательности вида u0431, и подпись «без скидки» превращается в нечитаемую строку. Выход простой: латиница и дефисы — empty-input, negative-price, а пояснения на русском — в комментариях рядом с кейсами.
Какие граничные значения стоит проверять параметризацией?
Классический набор краёв: пустая строка и пустой список, ноль и отрицательные числа, максимально допустимое значение, одиночный элемент. Функции ломаются на краях чаще, чем на обычных входах, поэтому на таблицу нормы заводят вторую таблицу краёв — часто с pytest.raises для отказов.
Понравился урок? Сошлитесь на него
«Имя кейса — это вывод отладки, который ты написал заранее: отчёт называет случай человеческим голосом.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 8
Параметризация: @pytest.mark.parametrize
Десять одинаковых тестов для десяти случаев — это десять копипаст. parametrize сжимает их в один тест и список кортежей, а pytest разворачивает обратно в десять отчётных строк.
pytest · Урок 9
Проверка исключений: pytest.raises
Хорошая функция на плохом входе не молчит, а бросает исключение — и тест обязан это проверять. pytest.raises делает это одной строкой внутри with.
pytest · Урок 15
conftest.py: общие фикстуры для всей папки
Фикстуры перестали быть домашними: conftest.py делает их общими для всей папки — без единого импорта, силами самого pytest.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
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 · Урок 18
Что тестировать: стратегия, границы и покрытие
Механику pytest ты знаешь — осталось ответить на главный вопрос: какие тесты писать. Поведение вместо реализации, границы диапазонов, ветки ошибок, покрытие и пирамида.
pytest покрытие кодаграничные значения тестирование