Тесты на параметризацию

pytest позволяет легко параметризовать тестовые функции. Основную документацию см. в разделе Как параметризировать приспособления и тестовые функции.

Далее мы приводим несколько примеров с использованием встроенных механизмов.

Генерирование комбинаций параметров в зависимости от командной строки

Допустим, мы хотим выполнить тест с различными параметрами вычислений, а диапазон параметров определяется аргументом командной строки. Сначала напишем простой (ничего не делающий) вычислительный тест:

# content of test_compute.py


def test_compute(param1):
    assert param1 < 4

Теперь мы добавляем тестовую конфигурацию следующим образом:

# content of conftest.py


def pytest_addoption(parser):
    parser.addoption("--all", action="store_true", help="run all combinations")


def pytest_generate_tests(metafunc):
    if "param1" in metafunc.fixturenames:
        if metafunc.config.getoption("all"):
            end = 5
        else:
            end = 2
        metafunc.parametrize("param1", range(end))

Это означает, что мы выполним только 2 теста, если не пройдем --all:

$ pytest -q test_compute.py
..                                                                   [100%]
2 passed in 0.12s

Мы выполняем только два вычисления, поэтому видим две точки. давайте выполним полный цикл:

$ pytest -q --all
....F                                                                [100%]
================================= FAILURES =================================
_____________________________ test_compute[4] ______________________________

param1 = 4

    def test_compute(param1):
>       assert param1 < 4
E       assert 4 < 4

test_compute.py:4: AssertionError
========================= short test summary info ==========================
FAILED test_compute.py::test_compute[4] - assert 4 < 4
1 failed, 4 passed in 0.12s

Как и ожидалось, при выполнении всего диапазона значений param1 мы получим ошибку на последнем из них.

Различные варианты идентификаторов тестов

pytest создаст строку, которая является идентификатором теста для каждого набора значений в параметризованном тесте. Эти идентификаторы могут быть использованы с помощью -k для выбора конкретных случаев для запуска, а также для идентификации конкретного случая при неудаче. Запуск pytest с --collect-only покажет сгенерированные идентификаторы.

Числа, строки, булевы и None будут иметь свое обычное строковое представление, используемое в идентификаторе теста. Для других объектов pytest создаст строку на основе имени аргумента:

# content of test_time.py

from datetime import datetime, timedelta

import pytest

testdata = [
    (datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1)),
    (datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1)),
]


@pytest.mark.parametrize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
    diff = a - b
    assert diff == expected


@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
def test_timedistance_v1(a, b, expected):
    diff = a - b
    assert diff == expected


def idfn(val):
    if isinstance(val, (datetime,)):
        # note this wouldn't show any hours/minutes/seconds
        return val.strftime("%Y%m%d")


@pytest.mark.parametrize("a,b,expected", testdata, ids=idfn)
def test_timedistance_v2(a, b, expected):
    diff = a - b
    assert diff == expected


@pytest.mark.parametrize(
    "a,b,expected",
    [
        pytest.param(
            datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1), id="forward"
        ),
        pytest.param(
            datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1), id="backward"
        ),
    ],
)
def test_timedistance_v3(a, b, expected):
    diff = a - b
    assert diff == expected

В test_timedistance_v0 мы позволяем pytest генерировать идентификаторы тестов.

В test_timedistance_v1 мы указали ids как список строк, которые использовались в качестве идентификаторов тестов. Они лаконичны, но могут быть неудобны в обслуживании.

В test_timedistance_v2 мы указали ids как функцию, которая может генерировать строковое представление, чтобы сделать его частью идентификатора теста. Таким образом, наши значения datetime используют метку, сгенерированную idfn, но поскольку мы не сгенерировали метку для объектов timedelta, они по-прежнему используют представление pytest по умолчанию:

$ pytest test_time.py --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 8 items

<Module test_time.py>
  <Function test_timedistance_v0[a0-b0-expected0]>
  <Function test_timedistance_v0[a1-b1-expected1]>
  <Function test_timedistance_v1[forward]>
  <Function test_timedistance_v1[backward]>
  <Function test_timedistance_v2[20011212-20011211-expected0]>
  <Function test_timedistance_v2[20011211-20011212-expected1]>
  <Function test_timedistance_v3[forward]>
  <Function test_timedistance_v3[backward]>

======================== 8 tests collected in 0.12s ========================

В test_timedistance_v3 мы использовали pytest.param, чтобы указать идентификаторы тестов вместе с фактическими данными, вместо того чтобы перечислять их отдельно.

Быстрый перенос «testscenarios»

Перед вами быстрый порт для запуска тестов, настроенных с помощью testscenarios, дополнения Роберта Коллинза для стандартного фреймворка unittest. Нам нужно лишь немного поработать, чтобы сконструировать правильные аргументы для pytest’s Metafunc.parametrize():

# content of test_scenarios.py


def pytest_generate_tests(metafunc):
    idlist = []
    argvalues = []
    for scenario in metafunc.cls.scenarios:
        idlist.append(scenario[0])
        items = scenario[1].items()
        argnames = [x[0] for x in items]
        argvalues.append([x[1] for x in items])
    metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class")


scenario1 = ("basic", {"attribute": "value"})
scenario2 = ("advanced", {"attribute": "value2"})


class TestSampleWithScenarios:
    scenarios = [scenario1, scenario2]

    def test_demo1(self, attribute):
        assert isinstance(attribute, str)

    def test_demo2(self, attribute):
        assert isinstance(attribute, str)

это полностью самодостаточный пример, с которым вы можете работать:

$ pytest test_scenarios.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 4 items

test_scenarios.py ....                                               [100%]

============================ 4 passed in 0.12s =============================

Если вы просто собираете тесты, вам также будет приятно увидеть „advanced“ и „basic“ в качестве вариантов для функции тестирования:

$ pytest --collect-only test_scenarios.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 4 items

<Module test_scenarios.py>
  <Class TestSampleWithScenarios>
    <Function test_demo1[basic]>
    <Function test_demo2[basic]>
    <Function test_demo1[advanced]>
    <Function test_demo2[advanced]>

======================== 4 tests collected in 0.12s ========================

Обратите внимание, что мы сказали metafunc.parametrize(), что значения вашего сценария должны рассматриваться как классово-скопированные. В pytest-2.3 это приводит к упорядочиванию на основе ресурсов.

Отсрочка установки параметризованных ресурсов

Параметризация тестовых функций происходит во время сбора. Хорошей идеей является настройка дорогостоящих ресурсов, таких как соединения с БД или подпроцесс, только во время выполнения реального теста. Вот простой пример, как этого можно добиться. Этот тест требует фиксации объекта db:

# content of test_backends.py

import pytest


def test_db_initialized(db):
    # a dummy test
    if db.__class__.__name__ == "DB2":
        pytest.fail("deliberately failing for demo purposes")

Теперь мы можем добавить тестовую конфигурацию, которая генерирует два вызова функции test_db_initialized, а также реализует фабрику, которая создает объект базы данных для фактических вызовов теста:

# content of conftest.py
import pytest


def pytest_generate_tests(metafunc):
    if "db" in metafunc.fixturenames:
        metafunc.parametrize("db", ["d1", "d2"], indirect=True)


class DB1:
    "one database object"


class DB2:
    "alternative database object"


@pytest.fixture
def db(request):
    if request.param == "d1":
        return DB1()
    elif request.param == "d2":
        return DB2()
    else:
        raise ValueError("invalid internal test config")

Давайте сначала посмотрим, как это выглядит во время сбора:

$ pytest test_backends.py --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items

<Module test_backends.py>
  <Function test_db_initialized[d1]>
  <Function test_db_initialized[d2]>

======================== 2 tests collected in 0.12s ========================

И затем, когда мы запускаем тест:

$ pytest -q test_backends.py
.F                                                                   [100%]
================================= FAILURES =================================
_________________________ test_db_initialized[d2] __________________________

db = <conftest.DB2 object at 0xdeadbeef0001>

    def test_db_initialized(db):
        # a dummy test
        if db.__class__.__name__ == "DB2":
>           pytest.fail("deliberately failing for demo purposes")
E           Failed: deliberately failing for demo purposes

test_backends.py:8: Failed
========================= short test summary info ==========================
FAILED test_backends.py::test_db_initialized[d2] - Failed: deliberately f...
1 failed, 1 passed in 0.12s

Первый вызов с db == "DB1" прошел, а второй с db == "DB2" не прошел. Наша функция приспособления db инстанцировала каждое из значений БД во время фазы установки, а pytest_generate_tests сгенерировала два соответствующих вызова test_db_initialized во время фазы сбора.

Косвенная параметризация

Использование параметра indirect=True при параметризации теста позволяет параметризировать тест с помощью приспособления, получающего значения перед передачей их в тест:

import pytest


@pytest.fixture
def fixt(request):
    return request.param * 3


@pytest.mark.parametrize("fixt", ["a", "b"], indirect=True)
def test_indirect(fixt):
    assert len(fixt) == 3

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

Применять косвенные на конкретные аргументы

Очень часто в параметризации используется более одного имени аргумента. Существует возможность применить параметр indirect к определенным аргументам. Это можно сделать, передав в indirect список или кортеж имен аргументов. В приведенном ниже примере есть функция test_indirect, которая использует два приспособления: x и y. Здесь мы передаем в indirect список, который содержит имя приспособления x. Параметр indirect будет применен только к этому аргументу, а значение a будет передано соответствующей функции приспособления:

# content of test_indirect_list.py

import pytest


@pytest.fixture(scope="function")
def x(request):
    return request.param * 3


@pytest.fixture(scope="function")
def y(request):
    return request.param * 2


@pytest.mark.parametrize("x, y", [("a", "b")], indirect=["x"])
def test_indirect(x, y):
    assert x == "aaa"
    assert y == "b"

Результат этого теста будет успешным:

$ pytest -v test_indirect_list.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 1 item

test_indirect_list.py::test_indirect[a-b] PASSED                     [100%]

============================ 1 passed in 0.12s =============================

Параметризация методов тестирования с помощью конфигурации для каждого класса

Вот пример функции pytest_generate_tests, реализующей схему параметризации, аналогичную схеме Майкла Фоорда unittest parametrizer, но в гораздо меньшем объеме кода:

# content of ./test_parametrize.py
import pytest


def pytest_generate_tests(metafunc):
    # called once per each test function
    funcarglist = metafunc.cls.params[metafunc.function.__name__]
    argnames = sorted(funcarglist[0])
    metafunc.parametrize(
        argnames, [[funcargs[name] for name in argnames] for funcargs in funcarglist]
    )


class TestClass:
    # a map specifying multiple argument sets for a test method
    params = {
        "test_equals": [dict(a=1, b=2), dict(a=3, b=3)],
        "test_zerodivision": [dict(a=1, b=0)],
    }

    def test_equals(self, a, b):
        assert a == b

    def test_zerodivision(self, a, b):
        with pytest.raises(ZeroDivisionError):
            a / b

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

$ pytest -q
F..                                                                  [100%]
================================= FAILURES =================================
________________________ TestClass.test_equals[1-2] ________________________

self = <test_parametrize.TestClass object at 0xdeadbeef0002>, a = 1, b = 2

    def test_equals(self, a, b):
>       assert a == b
E       assert 1 == 2

test_parametrize.py:21: AssertionError
========================= short test summary info ==========================
FAILED test_parametrize.py::TestClass::test_equals[1-2] - assert 1 == 2
1 failed, 2 passed in 0.12s

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

Вот урезанный реальный пример использования параметризованного тестирования для проверки сериализации объектов между различными интерпретаторами python. Мы определяем функцию test_basic_objects, которая должна быть запущена с различными наборами аргументов для своих трех аргументов:

  • python1: первый интерпретатор python, запускается для pickle-dump объекта в файл

  • python2: второй интерпретатор, запускается для загрузки объекта из файла с помощью pickle

  • obj: объект для сброса/загрузки

"""
module containing a parametrized tests testing cross-python
serialization via the pickle module.
"""
import shutil
import subprocess
import textwrap

import pytest

pythonlist = ["python3.5", "python3.6", "python3.7"]


@pytest.fixture(params=pythonlist)
def python1(request, tmp_path):
    picklefile = tmp_path / "data.pickle"
    return Python(request.param, picklefile)


@pytest.fixture(params=pythonlist)
def python2(request, python1):
    return Python(request.param, python1.picklefile)


class Python:
    def __init__(self, version, picklefile):
        self.pythonpath = shutil.which(version)
        if not self.pythonpath:
            pytest.skip(f"{version!r} not found")
        self.picklefile = picklefile

    def dumps(self, obj):
        dumpfile = self.picklefile.with_name("dump.py")
        dumpfile.write_text(
            textwrap.dedent(
                r"""
                import pickle
                f = open({!r}, 'wb')
                s = pickle.dump({!r}, f, protocol=2)
                f.close()
                """.format(
                    str(self.picklefile), obj
                )
            )
        )
        subprocess.check_call((self.pythonpath, str(dumpfile)))

    def load_and_is_true(self, expression):
        loadfile = self.picklefile.with_name("load.py")
        loadfile.write_text(
            textwrap.dedent(
                r"""
                import pickle
                f = open({!r}, 'rb')
                obj = pickle.load(f)
                f.close()
                res = eval({!r})
                if not res:
                    raise SystemExit(1)
                """.format(
                    str(self.picklefile), expression
                )
            )
        )
        print(loadfile)
        subprocess.check_call((self.pythonpath, str(loadfile)))


@pytest.mark.parametrize("obj", [42, {}, {1: 3}])
def test_basic_objects(python1, python2, obj):
    python1.dumps(obj)
    python2.load_and_is_true(f"obj == {obj}")

Его запуск приводит к некоторым пропускам, если у нас не установлены все интерпретаторы python, а в противном случае выполняются все комбинации (3 интерпретатора, 3 интерпретатора, 3 объекта для сериализации/десериализации):

. $ pytest -rs -q multipython.py
sssssssssssssssssssssssssss                                          [100%]
========================= short test summary info ==========================
SKIPPED [9] multipython.py:29: 'python3.5' not found
SKIPPED [9] multipython.py:29: 'python3.6' not found
SKIPPED [9] multipython.py:29: 'python3.7' not found
27 skipped in 0.12s

Косвенная параметризация необязательных реализаций/импортов

Если вы хотите сравнить результаты нескольких реализаций данного API, вы можете написать тестовые функции, которые получают уже импортированные реализации и пропускают их в случае, если реализация не импортируется/не доступна. Допустим, у нас есть «базовая» реализация, а другие (возможно, оптимизированные) должны давать схожие результаты:

# content of conftest.py

import pytest


@pytest.fixture(scope="session")
def basemod(request):
    return pytest.importorskip("base")


@pytest.fixture(scope="session", params=["opt1", "opt2"])
def optmod(request):
    return pytest.importorskip(request.param)

А затем базовая реализация простой функции:

# content of base.py
def func1():
    return 1

И оптимизированная версия:

# content of opt1.py
def func1():
    return 1.0001

И, наконец, небольшой тестовый модуль:

# content of test_module.py


def test_func1(basemod, optmod):
    assert round(basemod.func1(), 3) == round(optmod.func1(), 3)

Если вы запустите этот процесс с включенным отчетом о пропусках:

$ pytest -rs test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items

test_module.py .s                                                    [100%]

========================= short test summary info ==========================
SKIPPED [1] conftest.py:12: could not import 'opt2': No module named 'opt2'
======================= 1 passed, 1 skipped in 0.12s =======================

Вы увидите, что у нас нет модуля opt2, и поэтому второй тестовый запуск нашего test_func1 был пропущен. Несколько замечаний:

  • функции приспособления в файле conftest.py являются «session-scoped», потому что нам не нужно импортировать их более одного раза

  • если у вас есть несколько тестовых функций и пропущенный импорт, вы увидите, что количество [1] увеличивается в отчете

  • вы можете наложить параметризацию в стиле @pytest.mark.parametrize на тестовые функции для параметризации входных/выходных значений.

Установите метки или идентификатор теста для отдельного параметризованного теста

Используйте pytest.param для нанесения меток или установки идентификатора теста для отдельного параметризованного теста. Например:

# content of test_pytest_param_example.py
import pytest


@pytest.mark.parametrize(
    "test_input,expected",
    [
        ("3+5", 8),
        pytest.param("1+7", 8, marks=pytest.mark.basic),
        pytest.param("2+4", 6, marks=pytest.mark.basic, id="basic_2+4"),
        pytest.param(
            "6*9", 42, marks=[pytest.mark.basic, pytest.mark.xfail], id="basic_6*9"
        ),
    ],
)
def test_eval(test_input, expected):
    assert eval(test_input) == expected

В этом примере у нас есть 4 параметризованных теста. За исключением первого теста, мы помечаем остальные три параметризованных теста пользовательским маркером basic, а для четвертого теста мы также используем встроенную метку xfail, чтобы указать, что этот тест ожидается неудачным. Для наглядности мы задаем идентификаторы тестов для некоторых тестов.

Затем запустите pytest в режиме verbose и только с маркером basic:

$ pytest -v -m basic
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 24 items / 21 deselected / 3 selected

test_pytest_param_example.py::test_eval[1+7-8] PASSED                [ 33%]
test_pytest_param_example.py::test_eval[basic_2+4] PASSED            [ 66%]
test_pytest_param_example.py::test_eval[basic_6*9] XFAIL             [100%]

=============== 2 passed, 21 deselected, 1 xfailed in 0.12s ================

В результате:

  • Было собрано четыре теста

  • Один тест был отменен, потому что у него нет метки basic.

  • Было выбрано три теста с меткой basic.

  • Тест test_eval[1+7-8] прошел, но имя является автогенерируемым и запутанным.

  • Тест test_eval[basic_2+4] прошел.

  • Ожидалось, что тест test_eval[basic_6*9] завершится неудачно, и он действительно завершился неудачно.

Параметризация условного повышения

Используйте pytest.raises() с декоратором pytest.mark.parametrize для написания параметризованных тестов, в которых одни тесты вызывают исключения, а другие - нет.

Может быть полезно использовать nullcontext в качестве дополнения к raises.

Например:

from contextlib import nullcontext as does_not_raise

import pytest


@pytest.mark.parametrize(
    "example_input,expectation",
    [
        (3, does_not_raise()),
        (2, does_not_raise()),
        (1, does_not_raise()),
        (0, pytest.raises(ZeroDivisionError)),
    ],
)
def test_division(example_input, expectation):
    """Test how much I know division."""
    with expectation:
        assert (6 / example_input) is not None

В приведенном выше примере первые три тестовых случая должны пройти без ошибок, а четвертый должен выдать ошибку ZeroDivisionError.

Вернуться на верх