Основные шаблоны и примеры

Передача значений тестовой функции с помощью опций командной строки

Предположим, что мы хотим написать тест, который зависит от опции командной строки. Вот как это делается:

# content of test_sample.py
def test_answer(cmdopt):
    if cmdopt == "type1":
        print("first")
    elif cmdopt == "type2":
        print("second")
    assert 0  # чтобы увидеть вывод

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

# content of conftest.py
import pytest


def pytest_addoption(parser):
    parser.addoption(
        "--cmdopt", action="store", default="type1", help="my option: type1 or type2"
    )


@pytest.fixture
def cmdopt(request):
    return request.config.getoption("--cmdopt")

Давайте запустим БЕЗ нашей новой опции:

$ pytest -q test_sample.py
F                                                                    [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________

cmdopt = 'type1'

    def test_answer(cmdopt):
        if cmdopt == "type1":
            print("first")
        elif cmdopt == "type2":
            print("second")
>       assert 0  # to see what was printed
E       assert 0

test_sample.py:6: AssertionError
--------------------------- Captured stdout call ---------------------------
first
1 failed in 0.12s

А теперь применим cmdopt:

$ pytest -q --cmdopt=type2
F                                                                    [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________

cmdopt = 'type2'

    def test_answer(cmdopt):
        if cmdopt == "type1":
            print("first")
        elif cmdopt == "type2":
            print("second")
>       assert 0  # to see what was printed
E       assert 0

test_sample.py:6: AssertionError
--------------------------- Captured stdout call ---------------------------
second
1 failed in 0.12s

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

Динамическое добавлений опций командной строки

С помощью Опции конфигурации можно статически добавить опцию командной строки для вашего проекта. Можно также динамически модифицировать аргументы командной строки перед их обработкой:

# setuptools plugin
import sys


def pytest_load_initial_conftests(args):
    if "xdist" in sys.modules:  # pytest-xdist plugin
        import multiprocessing

        num = max(multiprocessing.cpu_count() / 2, 1)
        args[:] = ["-n", str(num)] + args

Если у вас установлен xdist plugin, то теперь вы будете всегда прогонять тесты с использованием числа подпроцессов, близкого к параметрам вашего процессора. Запустим в пустой директории с нашим conftest.py:

$ pytest
=========================== 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 0 items

========================== no tests ran in 0.12s ===========================

Конролируем пропуск тестов с помощью опции командной строки

Добавим в файл conftest.py опцию --runslow, чтобы контролировать пропуск тестов с пометкой pytest.mark.slow:

# content of conftest.py

import pytest


def pytest_addoption(parser):
    parser.addoption(
        "--runslow", action="store_true", default=False, help="run slow tests"
    )


def pytest_configure(config):
    config.addinivalue_line("markers", "slow: mark test as slow to run")


def pytest_collection_modifyitems(config, items):
    if config.getoption("--runslow"):
        #  опция --runslow запрошена в командной строке: медленные тесты не пропускаем
        return
    skip_slow = pytest.mark.skip(reason="need --runslow option to run")
    for item in items:
        if "slow" in item.keywords:
            item.add_marker(skip_slow)

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

# content of test_module.py
import pytest


def test_func_fast():
    pass


@pytest.mark.slow
def test_func_slow():
    pass

Если запустим его, то «медленный» тест будет пропущен:

$ pytest -rs    # "-rs" means report details on the little 's'
=========================== 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 2 items

test_module.py .s                                                    [100%]

========================= short test summary info ==========================
SKIPPED [1] test_module.py:8: need --runslow option to run
======================= 1 passed, 1 skipped in 0.12s =======================

А теперь запустим и медленные тесты, применив нашу опцию --runslow:

$ pytest --runslow
=========================== 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 2 items

test_module.py ..                                                    [100%]

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

Настройка __tracebackhide__

Если у вас есть вспомогательная функция, которую вы используете в тесте, то можно использовать маркер pytest.fail, чтобы «уронить» тест с определенным сообщением. Вспомогательная функция не будет отображаться в трейсбэке, если вы примените опцию __tracebackhide__ где-нибудь в теле этой функции. Пример:

# content of test_checkconfig.py
import pytest


def checkconfig(x):
    __tracebackhide__ = True
    if not hasattr(x, "config"):
        pytest.fail("not configured: {}".format(x))


def test_something():
    checkconfig(42)

Настройка __tracebackhide__ влияет на то, ЧТО pytest выводит в трейсбэке: функция checkconfig не будет показана, пока в командной строке не будет применена опция --full-trace. Давайте запустим наш маленький тест:

$ pytest -q test_checkconfig.py
F                                                                    [100%]
================================= FAILURES =================================
______________________________ test_something ______________________________

    def test_something():
>       checkconfig(42)
E       Failed: not configured: 42

test_checkconfig.py:11: Failed
1 failed in 0.12s

Если вы хотите скрыть только определенные исключения, можно сопоставить __tracebackhide__ объект, который, в свою очередь, вернет объект ExceptionInfo. Это можно использовать, к примеру, для того чтобы убедиться, что неожиданные исключения будут отображены:

import operator
import pytest


class ConfigException(Exception):
    pass


def checkconfig(x):
    __tracebackhide__ = operator.methodcaller("errisinstance", ConfigException)
    if not hasattr(x, "config"):
        raise ConfigException("not configured: {}".format(x))


def test_something():
    checkconfig(42)

Такое решение позволит избежать скрытия в трассировке неожиданных исключений.

Как определить, запущено ли приложение из pytest

Вообще-то, заставлять приложение вести себя по-другому при тестировании - плохая идея. Но если уж совершенно необходимо выяснить, запускается ли приложение из теста или нет, можно сделать как-то так:

# content of your_module.py


_called_from_test = False
# content of conftest.py


def pytest_configure(config):
    your_module._called_from_test = True

И потом проверять флажок your_module._called_from_test:

if your_module._called_from_test:
    # запущено для тестирования
    ...
else:
    # запущено "нормально"
    ...

в самом приложении

Добавление информации к заголовку отчета

Добавить дополнительную информацию к запуску pytest легко:

# content of conftest.py


def pytest_report_header(config):
    return "project deps: mylib-1.1"

При выводе эта строка отобразится в заголовке:

$ pytest
=========================== 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
project deps: mylib-1.1
rootdir: $REGENDOC_TMPDIR
collected 0 items

========================== no tests ran in 0.12s ===========================

Можно возвращать список строк - для каждого элемента списка будет добавлена отдельная строка. Можно также рассмотреть config.getoption('verbose') для получения подробной информации:

# content of conftest.py


def pytest_report_header(config):
    if config.getoption("verbose") > 0:
        return ["info1: did you know that ...", "did you?"]

Эта строка будет добавлена только при использовании опции --v:

$ pytest -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
info1: did you know that ...
did you?
rootdir: $REGENDOC_TMPDIR
collecting ... collected 0 items

========================== no tests ran in 0.12s ===========================

А без этой опции вывод не изменится:

$ pytest
=========================== 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 0 items

========================== no tests ran in 0.12s ===========================

Определение продолжительности выполнения тестов

Если у вас есть медленно выполняющийся огромный набор тестов, то может возникнуть желание выяснить, какие тесты самые медленные. Давайте создадим искусственный тестовый набор:

# content of test_some_are_slow.py
import time


def test_funcfast():
    time.sleep(0.1)


def test_funcslow1():
    time.sleep(0.2)


def test_funcslow2():
    time.sleep(0.3)

Теперь мы можем выяснить продолжительность трех самых медленных тестов с помощью --durations=3:

$ pytest --durations=3
=========================== 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 3 items

test_some_are_slow.py ...                                            [100%]

========================= slowest 3 test durations =========================
0.30s call     test_some_are_slow.py::test_funcslow2
0.20s call     test_some_are_slow.py::test_funcslow1
0.11s call     test_some_are_slow.py::test_funcfast
============================ 3 passed in 0.12s =============================

Тестирование по шагам (incremental testing)

Иногда тесты могут состоять из нескольких серий, и выполнять их надо по шагам. Если на каком-то шаге тест упал, нет смысла выполнять следующие шаги этой серии, поскольку они в любом случае должны упасть и трейсбэк не пополнится никакой полезной информацией. Ниже - пример файла conftest.py, который вводдит маркер incremental для использования с классами:

# content of conftest.py

# сохраняем историю падений в разрезе имен классов и индексов в параметризации (если она используется)
_test_failed_incremental: Dict[str, Dict[Tuple[int, ...], str]] = {}


def pytest_runtest_makereport(item, call):
    if "incremental" in item.keywords:
        # используется маркер incremental
        if call.excinfo is not None:
            # тест упал
            # извлекаем из теста имя класса
            cls_name = str(item.cls)
            # извлекаем индексы теста (если вместе с  incremental используется параметризация)
            parametrize_index = (
                tuple(item.callspec.indices.values())
                if hasattr(item, "callspec")
                else ()
            )
            # извлекаем имя тестовой функции
            test_name = item.originalname or item.name
            # сохраняем в _test_failed_incremental оригинальное имя упавшего теста
            _test_failed_incremental.setdefault(cls_name, {}).setdefault(
                parametrize_index, test_name
            )


def pytest_runtest_setup(item):
    if "incremental" in item.keywords:
        # извлекаем из теста имя класса
        cls_name = str(item.cls)
        # проверяем, падал ли предыдущий тест на этом классе
        if cls_name in _test_failed_incremental:
            # извлекаем индексы теста (если вместе с  incremental используется параметризация)
            parametrize_index = (
                tuple(item.callspec.indices.values())
                if hasattr(item, "callspec")
                else ()
            )
            # извлекаем имя первой тестовой функции, которая должна упасть для этого имени класса и индекса
            test_name = _test_failed_incremental[cls_name].get(parametrize_index, None)
            # если нашли такое имя, значит, тест падал для такой комбинации класса & фукнкции
            if test_name is not None:
                pytest.xfail("previous test failed ({})".format(test_name))

Эти два хука совместно работают на прерывание маркированных incremental тестов в классе. Вот пример тестового модуля:

# content of test_step.py

import pytest


@pytest.mark.incremental
class TestUserHandling:
    def test_login(self):
        pass

    def test_modification(self):
        assert 0

    def test_deletion(self):
        pass


def test_normal():
    pass

Запустим:

$ pytest -rx
=========================== 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_step.py .Fx.                                                    [100%]

================================= FAILURES =================================
____________________ TestUserHandling.test_modification ____________________

self = <test_step.TestUserHandling object at 0xdeadbeef>

    def test_modification(self):
>       assert 0
E       assert 0

test_step.py:11: AssertionError
========================= short test summary info ==========================
XFAIL test_step.py::TestUserHandling::test_deletion
  reason: previous test failed (test_modification)
================== 1 failed, 2 passed, 1 xfailed in 0.12s ==================

Посокольку test_modification упал, test_deletion не выполнялся и попал в отчет как «ожидаемо падающий» (xfailed).

Фикстуры уровня пакета/каталога (setups)

Если в вашем дереве тестов есть вложенные каталоги, можно каждый из них рассматривать как область действия фикстур - для этого достаточно разместить фикстуры в файле conftest.py соответствующего каталога. При этом можно использовать все типы фикстур, включая фикстуры autouse - аналоги setup/teardown функций xUnit Однако имейте в виду, что рекомендуется явно ссылаться на фикстуры в тестах и классах, вместо того, чтобы полагаться на неявное выполнение setup/teardown функций, особенно если они расположены далеко от использующих их тестов.

Вот пример, как сделать фикстуру db доступной в каталоге:

# content of a/conftest.py
import pytest


class DB:
    pass


@pytest.fixture(scope="session")
def db():
    return DB()

И тестовый модуль в этой же директории:

# content of a/test_db.py
def test_a1(db):
    assert 0, db  # to show value

Еще один тестовый модуль:

# content of a/test_db2.py
def test_a2(db):
    assert 0, db  # to show value

А этот модуль расположен в соседней (сестринской) директории, и там фикстура db будет не видна:

# content of b/test_error.py
def test_root(db):  # no db here, will error out
    pass

Теперь запустим:

$ pytest
=========================== 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 7 items

test_step.py .Fx.                                                    [ 57%]
a/test_db.py F                                                       [ 71%]
a/test_db2.py F                                                      [ 85%]
b/test_error.py E                                                    [100%]

================================== ERRORS ==================================
_______________________ ERROR at setup of test_root ________________________
file $REGENDOC_TMPDIR/b/test_error.py, line 1
  def test_root(db):  # no db here, will error out
E       fixture 'db' not found
>       available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
>       use 'pytest --fixtures [testpath]' for help on them.

$REGENDOC_TMPDIR/b/test_error.py:1
================================= FAILURES =================================
____________________ TestUserHandling.test_modification ____________________

self = <test_step.TestUserHandling object at 0xdeadbeef>

    def test_modification(self):
>       assert 0
E       assert 0

test_step.py:11: AssertionError
_________________________________ test_a1 __________________________________

db = <conftest.DB object at 0xdeadbeef>

    def test_a1(db):
>       assert 0, db  # to show value
E       AssertionError: <conftest.DB object at 0xdeadbeef>
E       assert 0

a/test_db.py:2: AssertionError
_________________________________ test_a2 __________________________________

db = <conftest.DB object at 0xdeadbeef>

    def test_a2(db):
>       assert 0, db  # to show value
E       AssertionError: <conftest.DB object at 0xdeadbeef>
E       assert 0

a/test_db2.py:2: AssertionError
============= 3 failed, 2 passed, 1 xfailed, 1 error in 0.12s ==============

Оба тестовых модуля из каталога a видят одну и ту же фикстуру db, а вот модуль из каталога b ее не видит. Конечно, мы можем так же определить фикстуру db в файле b/conftest.py. Обратите внимание, что каждая фикстура создается, только если требуется в тесте (кроме autouse фикстур - они всегда выполняются перед запуском тестов).

Обработка отчетов

Если нужно обрабатывать отчеты pytest или получать доступ к исполняющему тесты окружению, можно реализовать хук, который будет вызываться во время создания объекта «report». Ниже мы обрабатываем все упавшие тесты и получаем доступ к фикстуре (если она используется в тестах), которую хотим посмотреть во время обработки. Всю информацию мы запишем в файл failures:

# content of conftest.py

import pytest
import os.path


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    # выполняем все остальные хуки, чтобы получить report object
    outcome = yield
    rep = outcome.get_result()

    # мы ищем только вызовы упавших тестов, а не setup/teardown
    if rep.when == "call" and rep.failed:
        mode = "a" if os.path.exists("failures") else "w"
        with open("failures", mode) as f:
            # давайте ради прикола посмотрим на фикстуру
            if "tmpdir" in item.fixturenames:
                extra = " ({})".format(item.funcargs["tmpdir"])
            else:
                extra = ""

            f.write(rep.nodeid + extra + "\n")

Допустим, у нас есть тесты:

# content of test_module.py
def test_fail1(tmpdir):
    assert 0


def test_fail2():
    assert 0

Запустим их:

$ pytest test_module.py
=========================== 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 2 items

test_module.py FF                                                    [100%]

================================= FAILURES =================================
________________________________ test_fail1 ________________________________

tmpdir = local('PYTEST_TMPDIR/test_fail10')

    def test_fail1(tmpdir):
>       assert 0
E       assert 0

test_module.py:2: AssertionError
________________________________ test_fail2 ________________________________

    def test_fail2():
>       assert 0
E       assert 0

test_module.py:6: AssertionError
============================ 2 failed in 0.12s =============================

Мы получили файл failures с идентификаторами упавших тестов:

$ cat failures
test_module.py::test_fail1 (PYTEST_TMPDIR/test_fail10)
test_module.py::test_fail2

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

Если вы хотите, чтобы отчеты о результатах тестов были доступны в финализирующей фикстуре, можно реализовать следующий небольшой плагин:

# content of conftest.py

import pytest


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    # выполняем все остальные хуки до получения report object
    outcome = yield
    rep = outcome.get_result()

    # устанавливаем атрубут отчета на каждом этапе вызова:
    # "setup", "call", "teardown"

    setattr(item, "rep_" + rep.when, rep)


@pytest.fixture
def something(request):
    yield
    # "request.node" в данном случае "item", поскольку по мы используем уровень
    # по умолчанию - "function" scope
    if request.node.rep_setup.failed:
        print("setting up a test failed!", request.node.nodeid)
    elif request.node.rep_setup.passed:
        if request.node.rep_call.failed:
            print("executing test failed", request.node.nodeid)

Теперь, пусть у нас есть неудачные тесты:

# content of test_module.py

import pytest


@pytest.fixture
def other():
    assert 0


def test_setup_fails(something, other):
    pass


def test_call_fails(something):
    assert 0


def test_fail2():
    assert 0

Запустим их:

$ pytest -s test_module.py
=========================== 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 3 items

test_module.py Esetting up a test failed! test_module.py::test_setup_fails
Fexecuting test failed test_module.py::test_call_fails
F

================================== ERRORS ==================================
____________________ ERROR at setup of test_setup_fails ____________________

    @pytest.fixture
    def other():
>       assert 0
E       assert 0

test_module.py:7: AssertionError
================================= FAILURES =================================
_____________________________ test_call_fails ______________________________

something = None

    def test_call_fails(something):
>       assert 0
E       assert 0

test_module.py:15: AssertionError
________________________________ test_fail2 ________________________________

    def test_fail2():
>       assert 0
E       assert 0

test_module.py:19: AssertionError
======================== 2 failed, 1 error in 0.12s ========================

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

Переменная окружения PYTEST_CURRENT_TEST

Иногда тестовая сессия может зависнуть, и бывает непросто выяснить, на каком именно тесте она «застряла» (например, pytest запущен в «тихом» (-q) режиме или нет доступа к консольному выводу). Это особенно неприятно, когда проблема возникает нерегулярно - получаем так называемые «мерцающие» (flaky) тесты.

При запуске тестов pytest задает переменную окружения PYTEST_CURRENT_TEST, которую можно проверять с помощью утилит мониторинга процессов или библиотек вроде psutil для того, чтобы выяснить, какой именно тест «застрял»:

import psutil

for pid in psutil.pids():
    environ = psutil.Process(pid).environ()
    if "PYTEST_CURRENT_TEST" in environ:
        print(f'pytest process {pid} running: {environ["PYTEST_CURRENT_TEST"]}')

Во время тестовой сессии pytest будет присваивать PYTEST_CURRENT_TEST текущий идентификатор узла (nodeid) и текущее состояние: setup, call или teardown.

К примеру, если мы запустим одну тестовую функцию test_foo из модуля foo_module.py, PYTEST_CURRENT_TEST будет принимать следующие значения:

  1. foo_module.py::test_foo (setup)

  2. foo_module.py::test_foo (call)

  3. foo_module.py::test_foo (teardown)

Именно в таком порядке.

Примечание

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

«Заморозка» pytest

Если вы «замораживаете» приложение с помощью инструмента вроде PyInstaller, чтобы распространить его среди конечных пользователей, хорошей идеей будет упаковать и ваш pytest и запускать тесты с «замороженным» приложением. Благодаря такому способу некоторые ошибки (например, отсутствие в исполняемом файле нужных зависимостей) могут быть обнаружены на раннем этапе; кроме того, это позволяет вам отправлять тестовые файлы пользователям, чтобы они сами могли запустить тесты на своих машинах, что может быть полезно для получения дополнительной информации о трудновоспроизводимой ошибке.

К счастью, в последних релизах PyInstaller уже есть хук для pytest, но если вы используете для «заморозки» другие инструменты, такие как cx_freeze или py2exe, можно использовать pytest.freeze_includes() для получения полного списка используемых pytest модулей. Однако конфигурирование инструмента для поиска внутренних модулей зависит от используемого инструмента.

Вместо того, чтобы «заморозить» pytest как отдельный исполняемый файл, можно заставить «замороженную» программу воспринимать pytest как некий хитрый аргумент, к которому она обращается во время запуска. Это позволит вам иметь один исполняемый файл - обычно так удобнее. Обратите внимание, что механизм поиска плагинов, используемый pytest, не работает с «замороженными» исполняемыми файлами, поэтому pytest не сможет найти сторонний плагин автоматически. Чтобы подключить стороние плагины вроде pytest-timeout, их нужно явно импортировать и передать в pytest.main.

# contents of app_main.py
import sys
import pytest_timeout  # сторонний плагин

if len(sys.argv) > 1 and sys.argv[1] == "--pytest":
    import pytest

    sys.exit(pytest.main(sys.argv[2:], plugins=[pytest_timeout]))
else:
    # нормальное выполнение приложения: здесь можно проанализировать argv
    # как обычно
    ...

Такой шаблон позволит вам запускать тесты на «замороженном» приложении со стандартными опциями командной строки pytest:

./app_main --pytest --verbose --tb=long --junitxml=results.xml test-suite/