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

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

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

Параметризация продвинутая: 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 процентов это и есть четверть.

тот же файл, если ids кириллицей
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%]
Настоящие строки отчёта pytest: кириллические подписи «обычная скидка» и «всё бесплатно» экранированы в escape-последовательности. Тесты работают, но имени кейса больше никто не читает.

Собирательное правило хорошего имени: прочёл в отчёте — понял случай без открытия файла. Практика, которая работает:

  • короткое имя роли случая: ten-percent, empty-input, negative-price;
  • латиница и дефисы — пробелы и кириллица в отчёте выглядят плохо;
  • имя не повторяет значения, а называет их смысл;
  • подписи идут в том же порядке, что и кортежи, — держи их рядом в коде.

Стекирование: два набора — все комбинации

Второй приём — стекирование: несколько декораторов parametrize над одним тестом. pytest берёт декартово произведение: каждый кейс первого набора прогоняется с каждым кейсом второго. Проверим функцию clamp, которая зажимает значение в диапазон, на трёх значениях и двух диапазонах:

декораторы стекируются: 3 x 2 = 6 запусков
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 это видно по подписям: значения верхнего набора идут внешним слоем. Для большинства тестов порядок не важен — важно, что ни одна комбинация не потерялась.

ids на стекированных наборах
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 принимает и функцию: ей передаётся значение параметра, а она возвращает строку-подпись. Удобно строить имена из самого значения:

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, и оба обязаны упереться в отказ. Такие двойные границы ловят ошибки типов: функция, сравнивающая вход через == против числа, может пропустить один из вариантов — а таблица краёв заметит это за один прогон.

Для строковых функций таблица краёв своя: пустая строка, один символ, строка из пробелов, юникод с ударениями и эмодзи вместо букв. Механика та же: отдельный набор параметров, отдельные ожидания. Функции, которые наивно режут по первому символу или считают байты вместо символов, ловятся именно такими кейсами — не случайно они первые в чек-листах ревью.

ПриёмКак выглядитЧто даёт
idsparametrize(..., ids=["ten-percent"])человеческие имена кейсов в отчёте
стекированиедва @parametrize над одним тестомдекартово произведение наборов
отдельный набор краёвплохие входы + pytest.raisesграницы и отказы без смешивания с нормой
ids по умолчаниюподпись из значений: [1000-10-900.0]работает, но читается хуже

Имя кейса путешествует дальше локальной консоли: в отчётах CI, в скриншотах падений, в ссылках из багтрекера. Строка FAILED test_discount[quarter] в чате команды самодостаточна — не нужно открывать файл, чтобы понять, о каком случае речь. В этом и смысл имён: они делают отчёты переносимыми между людьми и системами.

Именованные кейсы работают и как адрес для точечного запуска: флаг -k отбирает тесты по подстроке имени. Из таблицы скидок можно взять один кейс и погонять только его, пока чинишь:

запуск одного кейса по имени через -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)
Проверь себя
0 / 6

1. Что задаёт аргумент ids= в @pytest.mark.parametrize?

2. Два декоратора parametrize над одним тестом дают сколько запусков?

3. Почему плохие входы выносят в отдельный набор параметров?

4. Почему подпись «без скидки» плохой вариант для ids?

5. Какие значения стоит включать в таблицу краёв?

6. Влияет ли порядок декораторов parametrize на состав комбинаций?

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

Параметризуй тест функции to_upper(text): три кейса — ("hello", "HELLO"), ("", "") и ("a-b", "A-B") — с подписями ids: simple, empty, dash. Запусти файл через pytest.main с флагом -v и напечатай код выхода, чтобы видеть имена кейсов в отчёте.

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

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

TelegramVK

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

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