Работа с пользовательской маркировкой

Вот несколько примеров использования механизма маркировки.

Маркировка тестовых функций и отбор маркированных тестов для запуска

Тестовую функцию можно настроить с помощью маркировки:

# content of test_server.py

import pytest


@pytest.mark.webtest
def test_send_http():
    pass  # perform some webtest test for your app


def test_something_quick():
    pass


def test_another():
    pass


class TestClass:
    def test_method(self):
        pass

Теперь вы можете запускать тесты только с меткой webtest:

$ pytest -v -m webtest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 4 items / 3 deselected / 1 selected

test_server.py::test_send_http PASSED                                [100%]

===================== 1 passed, 3 deselected in 0.12s ======================

Можно и наоборот - запустить все тесты, кроме помеченных webtest:

$ pytest -v -m "not webtest"
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 4 items / 1 deselected / 3 selected

test_server.py::test_something_quick PASSED                          [ 33%]
test_server.py::test_another PASSED                                  [ 66%]
test_server.py::TestClass::test_method PASSED                        [100%]

===================== 3 passed, 1 deselected in 0.12s ======================

Отбор тестов по идентификатору узла (node ID)

В качестве позиционных аргументов для отбора тестов можно передать pytest один или несколько идентификаторов узлов (см. ниже). Это облегчает отбор тестов по именам модулей, классов, методов или функций:

$ pytest -v test_server.py::TestClass::test_method
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 1 item

test_server.py::TestClass::test_method PASSED                        [100%]

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

Можно выбрать и сам класс:

$ pytest -v test_server.py::TestClass
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 1 item

test_server.py::TestClass::test_method PASSED                        [100%]

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

Или отобрать сразу несколько узлов:

$ pytest -v test_server.py::TestClass test_server.py::test_send_http
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 2 items

test_server.py::TestClass::test_method PASSED                        [ 50%]
test_server.py::test_send_http PASSED                                [100%]

============================ 2 passed in 0.12s =============================

Примечание

Идентификаторы узлов имеют формат module.py::class::method или module.py::function. Тесты собираются по идентификатрам узлов, так что при передаче module.py::class будут выбраны все тестовые методы класса. Для каждого параметра параметризованной фикстуры или функции также создаются узлы, так что идентификатор для отбора конкретного параметризованного теста должен включать значение параметра, например, module.py::function[param].

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

Использование опции -k "выражение" для отбора тестов по именам

Опцию -k командной строки можно использовать, чтобы указать подстроку, которая должна присутствовать в именах тестов (при использовании опции -m проверяется точное совпадение). Это облегчает отбор тестов по именам.

При этом сопоставление строк производится без учета регистра. Запустим с модулем из примера выше:

$ pytest -v -k http  # running with the above defined example module
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 4 items / 3 deselected / 1 selected

test_server.py::test_send_http PASSED                                [100%]

===================== 1 passed, 3 deselected in 0.12s ======================

Можно также запустить все тесты, которые не содержат ключевого слова:

$ pytest -k "not send_http" -v
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 4 items / 1 deselected / 3 selected

test_server.py::test_something_quick PASSED                          [ 33%]
test_server.py::test_another PASSED                                  [ 66%]
test_server.py::TestClass::test_method PASSED                        [100%]

===================== 3 passed, 1 deselected in 0.12s ======================

Или отборать все тесты, в именах которых есть подстрока «http» или «quick»:

$ pytest -k "http or quick" -v
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 4 items / 2 deselected / 2 selected

test_server.py::test_send_http PASSED                                [ 50%]
test_server.py::test_something_quick PASSED                          [100%]

===================== 2 passed, 2 deselected in 0.12s ======================

Примечание

Если вы используете выражение вида "X and Y", то и X, и Y должны быть простыми именами без ключевых слов. К примеру, запуск с переданными "pass" или "from" приведет к синтаксической ошибке, поскольку "-k" вычисляет выражения с помощью Python-функции eval .

Однако, если аргумент -k является просто строкой, то таких ограничений нет, так же как и в случае с "-k 'не строка'". Можно указывать и числа, например, "-k 1.3", если ваши тесты параметризованы действительным числом "1.3".

Регистрация маркеров

Зарегистрировать свои маркеры несложно:

# content of pytest.ini
[pytest]
markers =
    webtest: mark a test as a webtest.

Можно запросить список маркеров для тестового набора и увидеть, что в списке появился только что зарегистрированный маркер webtest:

$ pytest --markers
@pytest.mark.webtest: mark a test as a webtest.

@pytest.mark.filterwarnings(warning): add a warning filter to the given test. see https://docs.pytest.org/en/latest/warnings.html#pytest-mark-filterwarnings

@pytest.mark.skip(reason=None): skip the given test function with an optional reason. Example: skip(reason="no way of currently testing this") skips the test.

@pytest.mark.skipif(condition): skip the given test function if eval(condition) results in a True value.  Evaluation happens within the module global context. Example: skipif('sys.platform == "win32"') skips the test if we are on the win32 platform. see https://docs.pytest.org/en/latest/skipping.html

@pytest.mark.xfail(condition, reason=None, run=True, raises=None, strict=False): mark the test function as an expected failure if eval(condition) has a True value. Optionally specify a reason for better reporting and run=False if you don't even want to execute the test function. If only specific exception(s) are expected, you can list them in raises, and if the test fails in other ways, it will be reported as a true failure. See https://docs.pytest.org/en/latest/skipping.html

@pytest.mark.parametrize(argnames, argvalues): call a test function multiple times passing in different arguments in turn. argvalues generally needs to be a list of values if argnames specifies only one name or a list of tuples of values if argnames specifies multiple names. Example: @parametrize('arg1', [1,2]) would lead to two calls of the decorated test function, one with arg1=1 and another with arg1=2.see https://docs.pytest.org/en/latest/parametrize.html for more info and examples.

@pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see https://docs.pytest.org/en/latest/fixture.html#usefixtures

@pytest.mark.tryfirst: mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible.

@pytest.mark.trylast: mark a hook implementation function such that the plugin machinery will try to call it last/as late as possible.

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

Примечание

Мы рекомендуем явно регистрировать маркеры так, чтобы:

  • Ваши маркеры определялись только в одном месте тестового набора

  • Получение списка маркеров с помощью pytest --markers давало правильный результат

  • Опечатки в маркерах рассматривались как ошибка при использовании опции --strict-markers.

Маркировка классов и модулей

Декоратор pytest.mark можно применять для классов, чтобы пометить все его тестовые методы:

# content of test_mark_classlevel.py
import pytest


@pytest.mark.webtest
class TestClass:
    def test_startup(self):
        pass

    def test_startup_and_more(self):
        pass

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

Можно также установить атрибут pytestmark тестовому классу TestClass таким образом:

import pytest


class TestClass:
    pytestmark = pytest.mark.webtest

или назначить список маркеров:

import pytest


class TestClass:
    pytestmark = [pytest.mark.webtest, pytest.mark.slowtest]

Можно также установить пометку на уровне модуля:

import pytest pytestmark = pytest.mark.webtest

равно как и список маркеров:

pytestmark = [pytest.mark.webtest, pytest.mark.slowtest]

В этом случае маркеры будут применяться (слева направо) ко всем функциям и методам модуля.

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

Если тест параметризован, то маркировка такого теста равносильна маркировке каждого экземпляра теста с конкретным параметром. Тем не менее, можно пометить и отдельный экземпляр параметризованного теста:

import pytest


@pytest.mark.foo
@pytest.mark.parametrize(
    ("n", "expected"), [(1, 2), pytest.param(1, 3, marks=pytest.mark.bar), (2, 3)]
)
def test_increment(n, expected):
    assert n + 1 == expected

В приведенном выше примере маркером «foo» окажутся помечены все три запускаемых теста, а вот маркер «bar» будет применен только ко второму. Тем же способом можно пометить skip и xfail тесты, см. Skip/xfail с параметризацией.

Настраиваемые маркеры и опции командной строки для контроля запуска тестов

Плагины могут предоставлять настраиваемые маркеры и реализовывать определенное поведение на их основе. Вот полноценный пример добавления опции командной строки и параметризованного маркера тестовой функции для запуска тестов в определенных виртуальных средах:

# content of conftest.py

import pytest


def pytest_addoption(parser):
    parser.addoption(
        "-E",
        action="store",
        metavar="NAME",
        help="only run tests matching the environment NAME.",
    )


def pytest_configure(config):
    # register an additional marker
    config.addinivalue_line(
        "markers", "env(name): mark test to run only on named environment"
    )


def pytest_runtest_setup(item):
    envnames = [mark.args[0] for mark in item.iter_markers(name="env")]
    if envnames:
        if item.config.getoption("-E") not in envnames:
            pytest.skip("test requires env in {!r}".format(envnames))

Вот тестовый файл с использованием этого плагина:

# content of test_someenv.py

import pytest


@pytest.mark.env("stage1")
def test_basic_db_operation():
    pass

и пример запуска теста в виртуальной среде, отличной от «stage1»:

$ pytest -E stage2
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item

test_someenv.py s                                                    [100%]

============================ 1 skipped in 0.12s ============================

А здесь мы запускаем тест в нужном виртуальном окружении:

$ pytest -E stage1
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item

test_someenv.py .                                                    [100%]

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

Опцию --markers всегда можно использовать для получения актуального списка доступных маркеров:

$ pytest --markers
@pytest.mark.env(name): mark test to run only on named environment

@pytest.mark.filterwarnings(warning): add a warning filter to the given test. see https://docs.pytest.org/en/latest/warnings.html#pytest-mark-filterwarnings

@pytest.mark.skip(reason=None): skip the given test function with an optional reason. Example: skip(reason="no way of currently testing this") skips the test.

@pytest.mark.skipif(condition): skip the given test function if eval(condition) results in a True value.  Evaluation happens within the module global context. Example: skipif('sys.platform == "win32"') skips the test if we are on the win32 platform. see https://docs.pytest.org/en/latest/skipping.html

@pytest.mark.xfail(condition, reason=None, run=True, raises=None, strict=False): mark the test function as an expected failure if eval(condition) has a True value. Optionally specify a reason for better reporting and run=False if you don't even want to execute the test function. If only specific exception(s) are expected, you can list them in raises, and if the test fails in other ways, it will be reported as a true failure. See https://docs.pytest.org/en/latest/skipping.html

@pytest.mark.parametrize(argnames, argvalues): call a test function multiple times passing in different arguments in turn. argvalues generally needs to be a list of values if argnames specifies only one name or a list of tuples of values if argnames specifies multiple names. Example: @parametrize('arg1', [1,2]) would lead to two calls of the decorated test function, one with arg1=1 and another with arg1=2.see https://docs.pytest.org/en/latest/parametrize.html for more info and examples.

@pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see https://docs.pytest.org/en/latest/fixture.html#usefixtures

@pytest.mark.tryfirst: mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible.

@pytest.mark.trylast: mark a hook implementation function such that the plugin machinery will try to call it last/as late as possible.

Передача callable-объекта настраиваемым маркерам

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

# content of conftest.py
import sys


def pytest_runtest_setup(item):
    for marker in item.iter_markers(name="my_marker"):
        print(marker)
        sys.stdout.flush()

Настраиваемый маркер может иметь свое множество позиционных и именованных аргументов, т. е. свойств args и kwargs, которые можно передать как с помощью вызова callable-объекта, так и спомощью pytest.mark.MARKER_NAME.with_args. В большинстве случаев оба метода работают одинаково.

Однако, если единственным позиционным аргументом является callable-объект без именованных аргументов, использование pytest.mark.MARKER_NAME(c) не передаст "c" в качестве позиционного аргумента, а просто обернет "c" нашим маркером (см. маркировка тестов). К счастью, на помощь приходит pytest.mark.MARKER_NAME.with_args

# content of test_custom_marker.py
import pytest


def hello_world(*args, **kwargs):
    return "Hello World"


@pytest.mark.my_marker.with_args(hello_world)
def test_with_args():
    pass

Результатом запуска будет:

$ pytest -q -s
Mark(name='my_marker', args=(<function hello_world at 0xdeadbeef>,), kwargs={})
.
1 passed in 0.12s

Мы видим, что у нашего настраиваемого маркера есть свое множество аргументов, одним из которых является функция hello_world. В этом и заключается ключевое различие между созданием маркера в качестве callable-объекта, который за кулисами вызывает __call__, и использованием with_args.

Считывание маркера, который используется в разных местах

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

# content of test_mark_three_times.py
import pytest

pytestmark = pytest.mark.glob("module", x=1)


@pytest.mark.glob("class", x=2)
class TestClass:
    @pytest.mark.glob("function", x=3)
    def test_something(self):
        pass

Здесь у нас маркер «glob» применяется к одной и той же функции три раза. Мы можем увидеть это, прописав в файле conftest.py:

# content of conftest.py
import sys


def pytest_runtest_setup(item):
    for mark in item.iter_markers(name="glob"):
        print("glob args={} kwargs={}".format(mark.args, mark.kwargs))
        sys.stdout.flush()

Давайте запустим без перехвата вывода и посмотрим, что получится:

$ pytest -q -s
glob args=('function',) kwargs={'x': 3}
glob args=('class',) kwargs={'x': 2}
glob args=('module',) kwargs={'x': 1}
.
1 passed in 0.12s

Маркировка зависящих от платформы тестов

Предпложим, что у нас есть тестовый набор, в котором мы используем маркеры pytest.mark.darwin, pytest.mark.win32 и т. п. для маркировки тестов, запускаемых на разных платформах. При этом в набор такжде входят тесты, которые должны проводиться на всех платформах, и они никак не помечены. Теперь, если вы хотите запустить тесты для конкретной платформы, может пригодиться такой плагин:

# content of conftest.py
#
import sys
import pytest

ALL = set("darwin linux win32".split())


def pytest_runtest_setup(item):
    supported_platforms = ALL.intersection(mark.name for mark in item.iter_markers())
    plat = sys.platform
    if supported_platforms and plat not in supported_platforms:
        pytest.skip("cannot run on platform {}".format(plat))

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

# content of test_plat.py

import pytest


@pytest.mark.darwin
def test_if_apple_is_evil():
    pass


@pytest.mark.linux
def test_if_linux_works():
    pass


@pytest.mark.win32
def test_if_win32_crashes():
    pass


def test_runs_everywhere():
    pass

Здесь два теста должны быть пропущены, а два выполнены:

$ pytest -rs # this option reports skip reasons
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 4 items

test_plat.py s.s.                                                    [100%]

========================= short test summary info ==========================
SKIPPED [2] $REGENDOC_TMPDIR/conftest.py:12: cannot run on platform linux
======================= 2 passed, 2 skipped in 0.12s =======================

Обратите внимание, что если вы определяете платформу с помощью маркера и опции -m, как показано ниже,

$ pytest -m linux
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 4 items / 3 deselected / 1 selected

test_plat.py .                                                       [100%]

===================== 1 passed, 3 deselected in 0.12s ======================

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

Автоматическое добавление маркеров на основе имен тестов

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

# content of test_module.py


def test_interface_simple():
    assert 0


def test_interface_complex():
    assert 0


def test_event_simple():
    assert 0


def test_something_else():
    assert 0

Мы хотим динамически маркировать тесты и можем сделать это в conftest.py:

# content of conftest.py

import pytest


def pytest_collection_modifyitems(items):
    for item in items:
        if "interface" in item.nodeid:
            item.add_marker(pytest.mark.interface)
        elif "event" in item.nodeid:
            item.add_marker(pytest.mark.event)

Теперь можно использовать для отбора опцию -m:

$ pytest -m interface --tb=short
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 4 items / 2 deselected / 2 selected

test_module.py FF                                                    [100%]

================================= FAILURES =================================
__________________________ test_interface_simple ___________________________
test_module.py:4: in test_interface_simple
    assert 0
E   assert 0
__________________________ test_interface_complex __________________________
test_module.py:8: in test_interface_complex
    assert 0
E   assert 0
===================== 2 failed, 2 deselected in 0.12s ======================

Или можно выполнить и «event», и «interface» тесты:

$ pytest -m "interface or event" --tb=short
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 4 items / 1 deselected / 3 selected

test_module.py FFF                                                   [100%]

================================= FAILURES =================================
__________________________ test_interface_simple ___________________________
test_module.py:4: in test_interface_simple
    assert 0
E   assert 0
__________________________ test_interface_complex __________________________
test_module.py:8: in test_interface_complex
    assert 0
E   assert 0
____________________________ test_event_simple _____________________________
test_module.py:12: in test_event_simple
    assert 0
E   assert 0
===================== 3 failed, 1 deselected in 0.12s ======================