Параметризация Pytest

Завершено

Декоратор @pytest.mark.parametrize() позволяет выполнять одну тестовую функцию несколько раз с различными входными значениями. Используйте его, если логика утверждения одинакова, но примеры различаются. Параметризация может снизить повторение и позволяет pytest отображать каждый входной элемент как отдельный тестовый элемент в отчете.

Декоратор обычно принимает два обязательных аргумента:

  • argnames: строка, разделенная запятыми, или список или кортеж строк с именами аргументов для передачи в тестовую функцию. "item"Например, , "test_input, expected", ["test_input", "expected"]и ("test_input", "expected").
  • argvalues: итерация значений или наборов параметров, используемых для этих аргументов. При одном имени аргумента каждый элемент передаётся как единое тестовое значение, даже если это значение является кортежем. При использовании нескольких имен аргументов каждый элемент должен предоставить одно значение для каждого имени аргумента, как правило, в виде кортежа, списка или pytest.param(...) вызова.

Необязательный ids аргумент позволяет настроить идентификатор теста, который pytest использует для метки каждого созданного тестового элемента. Более сложные варианты, такие как indirect и scope, полезны для таких случаев, как параметризованные фикстуры или дорогостоящие ресурсы; вы можете прочитать о них в руководстве по параметризации pytest и @pytest.mark.parametrize справочнике.

Note

Pytest передает значения параметров для тестирования as-is; он не копирует их. Если тест мутирует изменяемое значение параметра, например список или словарь, эта мутация может быть видна в последующих созданных случаях, получающих тот же объект.

Когда следует использовать параметризацию

Два распространенных сценария, в которых может потребоваться использовать @pytest.mark.parametrize() :

  • Когда цикл теста обрабатывает входные данные и повторяет одно и то же утверждение.
  • Если несколько тестовых функций утверждают одно и то же поведение с различными входными данными

Давайте сначала рассмотрим каждый пример без использования @pytest.mark.parametrize(), а затем с ним, чтобы показать, как это может упростить тестирование для поддержания и диагностики.

Циклы

Ниже приведен пример тестовой функции с циклом for:

def test_string_is_digit():
    items = ["1", "10", "33"]
    for item in items:
        assert item.isdigit()

Этот шаблон может быть трудно диагностировать при сбое одного входного ввода:

  • Неполный результат выполнения: Первое неудачное утверждение останавливает цикл, поэтому последующие значения не проверяются в процессе выполнения.
  • Результат одного теста: Pytest сообщает один собранный тест, а не один тестовый случай на входные данные.
  • Повторяющиеся повторные запуски: Исправление первого сбоя может показать еще один сбой только после повторного запуска теста.

Давайте модифицируем тест, чтобы включить два элемента, которые приведут к сбою утверждения.

def test_string_is_digit():
    items = ["No", "1", "10", "33", "Yes"]
    for item in items:
        assert item.isdigit()

При выполнении теста отображается только первый сбой, даже если в списке есть два недопустимых элемента. В примерах команд используется python -m pytest, который работает, когда Python доступен как python. Если оболочка использует python3 в macOS или Linux или py в Windows, замените следующую команду.

python -m pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________

    def test_string_is_digit():
        items = ["No", "1", "10", "33", "Yes"]
        for item in items:
>           assert item.isdigit()
E           AssertionError: assert False
E            +  where False = <built-in method isdigit of str object at 0x...>()
E            +    where <built-in method isdigit of str object at 0x...> = 'No'.isdigit

test_items.py:4: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================

Результаты проверки с использованием функции introspection в Pytest показывают привязанный str.isdigit метод и строковое значение, которое вызвало ошибку. Заголовки сеансов, сведения о платформе, адреса памяти и время выполнения могут различаться, поэтому в примерах эти данные опущены или заменены на ... и 0x.... Соответствующая информация заключается в том, что цикл останавливается при первом сбое ('No') и никогда не оценивается 'Yes'.

Это полезный случай для @pytest.mark.parametrize(). Перед обновлением теста давайте рассмотрим еще одну распространенную ситуацию, которая не включает for циклы.

Тесты, которые утверждают то же поведение

Группа тестов, которые подтверждают то же самое, также является хорошим кандидатом для @pytest.mark.parametrize(). Если предыдущий тест был перезаписан с одним тестом для каждого элемента, это позволит улучшить отчеты о сбоях, но это будет повторяться:

def test_is_digit_1():
    assert "1".isdigit()

def test_is_digit_10():
    assert "10".isdigit()

def test_is_digit_33():
    assert "33".isdigit()

Эти тесты более детализированные, так как сбой может быть связан с одним входным вводом. Хотя это может показаться необычным для нескольких аналогичных тестов, этот шаблон распространен в рабочих наборах тестов, которые пытаются сообщить о сбоях точно.

Однако повторяющиеся тесты приходят со следующими проблемами:

  • Код повторяется, что создает нагрузку на обслуживание.
  • Аналогичные функции легко обновлять несогласованно.
  • Добавление нового ввода требует копирования другого тестового тела, поэтому важные случаи могут быть пропущены.

Параметризация обеспечивает преимущество отчетов отдельных элементов теста без копирования одного и того же тела теста.

Как использовать параметризацию

Теперь, когда вы знаете о некоторых вариантах использования @pytest.mark.parametrize(), давайте обновим тест, использовавший цикл for с элементами, которые не прошли проверку.

Импорт pytestи применение @pytest.mark.parametrize() непосредственно над тестовой функцией:

import pytest

@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
    assert item.isdigit()

Перед выполнением тестов давайте рассмотрим изменения.

В этом примере декоратор получает два аргумента. Первый аргумент , называет аргумент "item", который pytest передает в тестовую функцию. Имя должно соответствовать параметру item в test_string_is_digit(item). Второй аргумент — это список значений, используемых pytest для созданных тестовых случаев.

Подробные отчеты об ошибках

За кулисами pytest собирает один тестовый элемент для каждого значения в списке параметров. Это означает, что успешные и провальные случаи сообщаются отдельно. Давайте посмотрим, что происходит при выполнении теста:

python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py F...F                                                      [100%]

=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________

item = 'No'

    @pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
    def test_string_is_digit(item):
>       assert item.isdigit()
E       AssertionError: assert False
E        +  where False = <built-in method isdigit of str object at 0x...>()
E        +    where <built-in method isdigit of str object at 0x...> = 'No'.isdigit

test_items.py:5: AssertionError
__________________________ test_string_is_digit[Yes] ___________________________

item = 'Yes'

    @pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
    def test_string_is_digit(item):
>       assert item.isdigit()
E       AssertionError: assert False
E        +  where False = <built-in method isdigit of str object at 0x...>()
E        +    where <built-in method isdigit of str object at 0x...> = 'Yes'.isdigit

test_items.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
FAILED test_items.py::test_string_is_digit[Yes] - AssertionError: assert False
========================= 2 failed, 3 passed in 0.07s ==========================

В тестовом отчете есть несколько важных элементов. Во-первых, pytest сообщает о пяти собранных элементах в одной тестовой функции: три успешных и два провальных. Сообщается о сбоях отдельно, включая входное значение, вызвавшее каждый сбой. Заголовок test_string_is_digit[No] показывает набор параметров, а локальный item = 'No' параметр отображается над исходным представлением. Строки интроспекции привязываемого метода (адреса памяти, показанные как 0x...) подтверждают, какое значение вызвало сбой утверждения:

___________________________ test_string_is_digit[No] ___________________________

item = 'No'
[...]
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False

Трудно пропустить значение, которое вызвало сбой с таким количеством мест, где он сообщается.

Использование подробного флага выходных данных

Когда тесты проходят успешно, отчеты в командной строке минимальны по умолчанию. Вот как будет выглядеть тест после обновления для устранения сбоев (продолжите редактирование test_items.py, поскольку import pytest уже присутствует):

@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
    assert item.isdigit()

В ходе тестирования вывод минимален.

python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py .....                                                      [100%]

============================== 5 passed in 0.01s ===============================

Повышение уровня детализации показывает каждый созданный тестовый случай и созданный для него идентификатор параметра.

python -m pytest -v test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py::test_string_is_digit[0] PASSED                            [ 20%]
test_items.py::test_string_is_digit[1] PASSED                            [ 40%]
test_items.py::test_string_is_digit[10] PASSED                           [ 60%]
test_items.py::test_string_is_digit[33] PASSED                           [ 80%]
test_items.py::test_string_is_digit[9] PASSED                            [100%]

============================== 5 passed in 0.01s ===============================

Использование нескольких имен аргументов

Примеры, которые мы видели до сих пор, используют одно имя аргумента: "item". Можно также включить несколько имен аргументов в первый аргумент. При использовании строки разделите имена запятыми. Вы также можете передать имена в виде списка или кортежа, например ["item", "attribute"] или ("item", "attribute").

Один из вариантов использования нескольких имен аргументов заключается в передаче входного значения и ожидаемого значения для сравнения в тестировании. Во втором аргументе каждый набор параметров должен иметь одно значение для каждого имени аргумента. Например, если имена аргументов являются "test_input, expected_value", значения аргументов могут быть [("3+5", 8), ("3*4", 12)].

В следующем примере используется функция Python hasattr(), которая возвращает логическое значение в зависимости от того, имеет ли объект именованный атрибут:

>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False

Для каждого тестового случая требуется объект и имя атрибута, поэтому мы можем использовать @pytest.mark.parametrize() следующим образом. Продолжайте работу в том же test_items.py файле, так что import pytest уже на месте.

@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

Декоратор @pytest.mark.parametrize() по-прежнему использует одну строку для первого аргумента, но эта строка теперь содержит два имена аргументов, разделенных запятой. Имена становятся аргументами для тестовой функции. В этом случае это они, item и attribute. Список или кортеж имен работает так же.

Второй аргумент — это список с двумя наборами параметров. Каждый набор параметров содержит одно item значение и одно attribute значение. Если набор параметров имеет слишком мало или слишком много значений для имен аргументов, pytest вызывает ошибку коллекции перед запуском тестовых случаев.

Pytest создает идентификатор теста для каждого набора параметров. Для простых значений, таких как числа, строки, логические значения и None, pytest использует обычное строковое представление значений. Для других объектов pytest возвращается к имени, основанному на имени аргумента, и индексу набора параметров, начинающемуся с нуля. В этом выходном данных пустая строка вносит пустой элемент идентификатора, поэтому разделительный дефис между двумя идентификаторами все еще отображается в [-format]. Поскольку у list() нет простого сгенерированного идентификатора, pytest заменяет item1:

python -m pytest -v test_items.py::test_attributes
============================= test session starts ==============================
...
collected 2 items

test_items.py::test_attributes[-format] PASSED                           [ 50%]
test_items.py::test_attributes[item1-append] PASSED                      [100%]

============================== 2 passed in 0.01s ===============================

Если созданные идентификаторы трудно считывать, можно передать необязательный аргумент ids или обернуть набор параметров в pytest.param(..., id="name"), чтобы указать более четкое имя. Рассмотрим пример.

import pytest


@pytest.mark.parametrize(
    "item, attribute",
    [
        pytest.param("", "format", id="empty-string-format"),
        (list(), "append"),
    ],
)
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

Вызов pytest.param("", "format", id="empty-string-format") предоставляет весь идентификатор для этого набора параметров, заменив автоматически созданный -format идентификатор. В подробном выводе созданные тесты называются test_attributes[empty-string-format] и test_attributes[item1-append]. Четкие идентификаторы упрощают сканирование подробных выходных данных и сообщений о сбоях.