Фикстуры pytest: явные, модальные, расширяемые

Тестовые фикстуры инициализируют тестовые функции. Они обеспечивают надежность тестов, согласованнность и повторямость их результатов. При инициализации можно настраивать сервисы, состояния, переменные окружения. Доступ к ним осуществляется через аргументы тестовых функций; для каждой фикстуры, используемой тестовой функцией, в самой функции, как правило, существует соответствующий аргумент, имя которого совпадает с наименованием фикстуры.

Фикстуры pytest значительно удобнее классических setup/teardown-функций xUnit, поскольку:

  • фикстуры имеют явные имена и активируются путем их объявления в тестовых функциях, модулях, классах и проектах.

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

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

При этом pytest продолжает поддерживать Классический «setup» в стиле xunit. Можно смешивать оба стиля, постепенно переходя от классического стиля к новому, если вам так нравится. Можно начинать с существующего стиля unittest.TestCase или с проектов nose.

Фикстуры определяются с использованием декоратора @pytest.fixture, описанного ниже. В pytest есть полезные встроенные фикстуры, см. список встроенных фикстур.

Фикстуры как аргументы функций

Тестовые функции принимают фикстуры как входящий аргумент с тем же именем. Для каждого такого аргумента функция-фикстура предоставляет объект фикстуры. Для того, чтобы зарегистировать функцию как фикстуру, нужно использовать декоратор @pytest.fixture. Давайте рассмотрим простой тестовый модуль, содержащий фикстуру и использующую ее тестовую функцию:

# content of ./test_smtpsimple.py
import pytest


@pytest.fixture
def smtp_connection():
    import smtplib

    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)


def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250
    assert 0  # в демонстрационных целях

Здесь test_ehlo использует значение фикстуры smtp_connection. При передаче аргумента pytest найдет и вызовет маркированную функцию-фикстуру smtp_connection. Запустив тест, увидим следующее:

$ pytest test_smtpsimple.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 1 item

test_smtpsimple.py F                                                 [100%]

================================= FAILURES =================================
________________________________ test_ehlo _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
>       assert 0  # for demo purposes
E       assert 0

test_smtpsimple.py:14: AssertionError
============================ 1 failed in 0.12s =============================

В трейсбеке мы видим, что тестовая функция была вызвана с аргументом smtp_connection - объектом smtplib.SMTP(), который был создан фикстурой. Тестовая функция упала на проверке assert 0. В данном случае pytest использует следующий алгоритм для вызова тестовой функции:

  1. pytest находит функцию test_ehlo по ее префиксу test_. Ей передается аргумент с именем smtp_connection, поэтому pytest ищет и находит функцию с именем smtp_connection, помеченную как фикстура.

  2. Фикстура smtp_connection() вызывается для создания объекта-функции.

  3. Затем вызывается функция test_ehlo(<объект smtp_connection>), выполняется и падает на последней строчке.

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

Примечание

Чтобы посмотреть список доступных фикстур, можно использовать опцию --fixtures:

pytest --fixtures test_simplefactory.py

При этом фикстуры с ведущим символом «_» будут выведены в список, только если вы используете опцию -v.

Фикстуры: яркий пример внедрения зависимостей

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

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

conftest.py: расширение фикстур

Если вы планируете использовать фикстуру в нескольких тестах, то можно объявить ее в файле conftest.py. При этом импортировать ее не нужно - pytest найдет ее автоматически. Поиск фикстур начинается с тестовых классов, затем они иущется в тестовых модулях и в файлах conftest.py, и, в последнюю очередь, во встроенных и сторонних плагинах.

В conftest.py также можно встраивать плагины для подкаталогов.

Расширение тестовых данных

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

Еще один хороший подход заключается в добавлении файлов с данными в папку tests. Существуют также плагины, которые помогают управлять этим аспектом тестирования, например, pytest-datadir, или pytest-datafiles.

Область действия (уровень) фикстуры: расширение фикстуры на все тесты класса, модуля, сессии

Фикстуры, требующие доступа к сети, зависят от подключения и обычно требуют больших временных затрат на их создание. Расширяя предыдущий пример, мы можем добавить параметр scope="module" в декоратор фикстуры @pytest.fixture , чтобы функция-фикстура smtp_connection вызывалась только один раз для тестового модуля (по умолчанию параметр scope установлен в значение function). Таким образом, каждая тестовая функция модуля получит тот же самый объект smtp_connection, что позволит сэкономить время на создание подключения. Возможными значениями параметра scope являются function, class, module, package или session.

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

# content of conftest.py
import pytest
import smtplib


@pytest.fixture(scope="module")
def smtp_connection():
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

Наша фикстура по-прежнему называется smtp_connection, и получить к ней доступ из любой тестовой функции или другой фикстуры (в пределах директории, в которой расположен наш файл conftest.py и ее поддиректорий) можно, передав параметр smtp_connection в объявлении нашей функции/фикстуры:

# content of test_module.py


def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250
    assert b"smtp.gmail.com" in msg
    assert 0  # в демонстрационных целях


def test_noop(smtp_connection):
    response, msg = smtp_connection.noop()
    assert response == 250
    assert 0  # в демонстрационных целях

Здесь мы специально вставляем обреченные на неудачу операторы 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_ehlo _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
        assert b"smtp.gmail.com" in msg
>       assert 0  # for demo purposes
E       assert 0

test_module.py:7: AssertionError
________________________________ test_noop _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_noop(smtp_connection):
        response, msg = smtp_connection.noop()
        assert response == 250
>       assert 0  # for demo purposes
E       assert 0

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

Мы видим, что оба оператора assert 0 упали. И, поскольку pytest показывает значения входящих параметров, мы также можем увидеть, что в обе тестовые функции был передан один и тот же объект smtp_connection (с уровнем модуля). В итоге, мы выполнили только одно smtp-подключение для обеих тестовых функций вместо двух.

Если вы предпочитаете создавать одно smtp-подключение на сессию, можно просто задать параметру scope значение session:

@pytest.fixture(scope="session")
def smtp_connection():
    # the returned fixture value will be shared for
    # all tests needing it
    ...

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

Примечание

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

Область действия package (экспериментальная возможность)

В pytest 3.7 была введена область действия package. Фикстура с такой областью действия работает, пока не будет выполнен последний тест пакета (package).

Предупреждение

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

Динамическая область действия

В некоторых случаях можно изменять область действия фикстуры без изменения кода. Чтобы этого добиться, сделайте фикстуру вызываемым объектом (callable). Этот объект должен возвращать строку с допустимым значением области действия, и выполнен он будет только один раз - во время определения фикстуры. Такой объект вызывается с двумя ключевыми параметрами: строкой fixture_name и конфигурируемым объектом config.

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

def determine_scope(fixture_name, config):
    if config.getoption("--keep-containers", None):
        return "session"
    return "function"


@pytest.fixture(scope=determine_scope)
def docker_container():
    yield spawn_container()

Порядок создания фикстур

При запросе фикстуры функцией сначала инициализиурются фикстуры с самой широкой областью действия - session и module, а затем - фикстуры более низкого уровня с областями class или function. В рамках одной тестовой функции порядок создания фикстур с одинаковой областью действия зависит от очередности вызова этих фикстур и установленных между ними зависимостей. При этом фикстуры с параметром autouse = True инициализируются прежде явно объявленных фикстур того же уровня.

Рассмотрим следующий код:

import pytest

# fixtures documentation order example
order = []


@pytest.fixture(scope="session")
def s1():
    order.append("s1")


@pytest.fixture(scope="module")
def m1():
    order.append("m1")


@pytest.fixture
def f1(f3):
    order.append("f1")


@pytest.fixture
def f3():
    order.append("f3")


@pytest.fixture(autouse=True)
def a1():
    order.append("a1")


@pytest.fixture
def f2():
    order.append("f2")


def test_order(f1, m1, f2, s1):
    assert order == ["s1", "m1", "a1", "f3", "f1", "f2"]

Фикстуры, запрошенные функцией test_order, будут инициализированы в следующем порядке:

  1. s1: фикстура с самой широкой областью действия (session).

  2. m1: фикстура второго уровня (module).

  3. a1: фикстура с областью действия function (function-scoped fixture) и параметром autouse = True: экземпляр этой фикстуры будет создан до создания остальных function-scoped фикстур.

  4. f3: function-scoped фикстура, которую запрашивает функция f1: ее нужно создать в момент запроса

  5. f1: первая function-scoped фикстура в списке аргументов функции test_order.

  6. f2: последняя function-scoped фикстура в списке аргументов функции test_order.

Финализаторы в фикстуре / выполнение завершающего кода

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

# content of conftest.py

import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
    yield smtp_connection  # возвращает значение фикстуры
    print("teardown smtp")
    smtp_connection.close()

Операторы print и smtp.close() будут выполнены после завершения последнего теста модуля независимо от того, было ли вызвано исключение или нет.

Давайте запустим:

$ pytest -s -q --tb=no
FFteardown smtp

========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
2 failed in 0.12s

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

Обратите внимание, что можно использовать синтаксис yield с оператором with:

# content of test_yield2.py

import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    with smtplib.SMTP("smtp.gmail.com", 587, timeout=5) as smtp_connection:
        yield smtp_connection  # возвращает значение фикстуры

После выполнения теста соединение smtp_connection будет разорвано, поскольку объект smtp_connection автоматически закрывается после завершения выполнения оператора with.

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

# content of test_yield3.py

import contextlib

import pytest


@contextlib.contextmanager
def connect(port):
    ...  # устанавливаем соединение
    yield
    ...  # разрываем соединение


@pytest.fixture
def equipments():
    with contextlib.ExitStack() as stack:
        yield [stack.enter_context(connect(port)) for port in ("C1", "C3", "C28")]

Если в приведенном примере попытка установить соединение "C28" будет неудачной, "C1" и "C3" все равно будут корректно разорваны.

Обратите внимание: если исключение было вызвано во время выполнения установочной части (до оператора yield), завершающий код (после yield) выполнен не будет.

Альтернативным способом добиться выполнения завершающего кода является использование метода addfinalizer объекта request-context для регистрации финализатора.

Вот пример использования addfinalizer для разрыва соединения в фикстуре smtp_connection:

# content of conftest.py
import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection(request):
    smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

    def fin():
        print("teardown smtp_connection")
        smtp_connection.close()

    request.addfinalizer(fin)
    return smtp_connection  # возвращает значение фикстуры

А вот пример фикстуры equipments с использованием addfinalizer:

# content of test_yield3.py

import contextlib
import functools

import pytest


@contextlib.contextmanager
def connect(port):
    ...  # устанавливаем соединение
    yield
    ...  # разрываем соединение


@pytest.fixture
def equipments(request):
    r = []
    for port in ("C1", "C3", "C28"):
        cm = connect(port)
        equip = cm.__enter__()
        request.addfinalizer(functools.partial(cm.__exit__, None, None, None))
        r.append(equip)
    return r

Оба метода - yield и addfinalizer - работают похоже, выполняя свою часть кода по завершении тестов. Конечно, если исключение будет вызвано до инициализации финализатора, ее код выполняться не будет.

Фикстуры могут анализировать запрашивающий контекст

Фикстура может принимать объект request для анализа контекста запрашивающей тестовой функции, класса или модуля. В продолжением предыдущего примера, давайте прочтем URL сервера из тестового модуля, который использует нашу фикстуру.

# content of conftest.py
import pytest
import smtplib


@pytest.fixture(scope="module")
def smtp_connection(request):
    server = getattr(request.module, "smtpserver", "smtp.gmail.com")
    smtp_connection = smtplib.SMTP(server, 587, timeout=5)
    yield smtp_connection
    print("finalizing {} ({})".format(smtp_connection, server))
    smtp_connection.close()

Здесь мы используем параметр request.module чтобы получить переменную smtpserver из модуля. Если мы просто запустим pytest, ничего особо не изменится:

$ pytest -s -q --tb=no
FFfinalizing <smtplib.SMTP object at 0xdeadbeef> (smtp.gmail.com)

========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
2 failed in 0.12s

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

# content of test_anothersmtp.py

smtpserver = "mail.python.org"  # будет прочитан фикстурой smtp_connection


def test_showhelo(smtp_connection):
    assert 0, smtp_connection.helo()

Запустим:

$ pytest -qq --tb=short test_anothersmtp.py
F                                                                    [100%]
================================= FAILURES =================================
______________________________ test_showhelo _______________________________
test_anothersmtp.py:6: in test_showhelo
    assert 0, smtp_connection.helo()
E   AssertionError: (250, b'mail.python.org')
E   assert 0
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef> (mail.python.org)

Вуаля! Фикстура smtp_connection взяла имя нашего почтового сервера из пространства имен использующего ее модуля.

Фикстура как фабрика данных

Шаблон «фабрика-фикстура» может помочь в ситуациях, когда результат, возвращаемый фикстурой используется много раз в отдельном тесте. Суть в том, что вместо того, чтобы напрямую возвращать данные, фикстура возвращает функцию, которая генерирует данные. И затем эта функция может быть неоднократно вызвана в тесте.

Если нужно, фабрики-фикстуры могут принимать параметры:

@pytest.fixture
def make_customer_record():
    def _make_customer_record(name):
        return {"name": name, "orders": []}

    return _make_customer_record


def test_customer_records(make_customer_record):
    customer_1 = make_customer_record("Lisa")
    customer_2 = make_customer_record("Mike")
    customer_3 = make_customer_record("Meredith")

Если нужно управлять данными, созданными фабриками, фикстура позаботится и об этом (в нашем случае, будут очищены созданные записи):

@pytest.fixture
def make_customer_record():

    created_records = []

    def _make_customer_record(name):
        record = models.Customer(name=name, orders=[])
        created_records.append(record)
        return record

    yield _make_customer_record

    for record in created_records:
        record.destroy()


def test_customer_records(make_customer_record):
    customer_1 = make_customer_record("Lisa")
    customer_2 = make_customer_record("Mike")
    customer_3 = make_customer_record("Meredith")

Параметризация фикстур

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

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

# content of conftest.py
import pytest
import smtplib


@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
    smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
    yield smtp_connection
    print("finalizing {}".format(smtp_connection))
    smtp_connection.close()

Главным внесенным изменением является объявление списка параметров params в декораторе @pytest.fixture <_pytest.python.fixture>, для каждого из которых фикстура будет выполняться и получать значение request.param. Менять код в тестовой функции не нужно. Давайте запустим на ш тестовый модуль «test_module.py»:

$ pytest -q test_module.py
FFFF                                                                 [100%]
================================= FAILURES =================================
________________________ test_ehlo[smtp.gmail.com] _________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
        assert b"smtp.gmail.com" in msg
>       assert 0  # for demo purposes
E       assert 0

test_module.py:7: AssertionError
________________________ test_noop[smtp.gmail.com] _________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_noop(smtp_connection):
        response, msg = smtp_connection.noop()
        assert response == 250
>       assert 0  # for demo purposes
E       assert 0

test_module.py:13: AssertionError
________________________ test_ehlo[mail.python.org] ________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
>       assert b"smtp.gmail.com" in msg
E       AssertionError: assert b'smtp.gmail.com' in b'mail.python.org\nPIPELINING\nSIZE 51200000\nETRN\nSTARTTLS\nAUTH DIGEST-MD5 NTLM CRAM-MD5\nENHANCEDSTATUSCODES\n8BITMIME\nDSN\nSMTPUTF8\nCHUNKING'

test_module.py:6: AssertionError
-------------------------- Captured stdout setup ---------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef>
________________________ test_noop[mail.python.org] ________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_noop(smtp_connection):
        response, msg = smtp_connection.noop()
        assert response == 250
>       assert 0  # for demo purposes
E       assert 0

test_module.py:13: AssertionError
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef>
4 failed in 0.12s

Мы видим, что каждая из пары наших тестовых функций была выполнена дважды, сначала с одним, потом с другим объектом smtp_connection. Обратите внимание, что с соединением mail.python.org второй тест упал на функции test_ehlo, поскольку мы ожидали найти в сообщении строку с другим названием сервера.

Для каждого значения параметра параметризованной фикстуры pytest сгенерирует ID - строку, которая его идентифицирует (например, строки test_ehlo[smtp.gmail.com] и test_ehlo[mail.python.org] для нашего кода). Можно использовать эти ID вместе с опцией -k для выбора отдельных вариантов теста для запуска. По этим же ID можно понять, какой именно параметр использовался в упавшем тесте. Если вы запустите pytest с опцией --collect-only, то сможете увидеть все сгенерированные ID.

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

# content of test_ids.py
import pytest


@pytest.fixture(params=[0, 1], ids=["spam", "ham"])
def a(request):
    return request.param


def test_a(a):
    pass


def idfn(fixture_value):
    if fixture_value == 0:
        return "eggs"
    else:
        return None


@pytest.fixture(params=[0, 1], ids=idfn)
def b(request):
    return request.param


def test_b(b):
    pass

Пример выше показывает, что ids можно определять как списком строк, так и функцией, которая будет вызвана со значением фикстуры и вернет строку. В последнем случае, если функция вернет None, то pytest сгенерирует ID автоматически.

При запуске тестов из примеров выше будут сгенерированы следующие ID:

$ pytest --collect-only
=========================== 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 10 items
<Module test_anothersmtp.py>
  <Function test_showhelo[smtp.gmail.com]>
  <Function test_showhelo[mail.python.org]>
<Module test_ids.py>
  <Function test_a[spam]>
  <Function test_a[ham]>
  <Function test_b[eggs]>
  <Function test_b[1]>
<Module test_module.py>
  <Function test_ehlo[smtp.gmail.com]>
  <Function test_noop[smtp.gmail.com]>
  <Function test_ehlo[mail.python.org]>
  <Function test_noop[mail.python.org]>

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

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

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

Пример:

# content of test_fixture_marks.py
import pytest


@pytest.fixture(params=[0, 1, pytest.param(2, marks=pytest.mark.skip)])
def data_set(request):
    return request.param


def test_data(data_set):
    pass

При выполнении этого теста вызов data_set со значением 2 будет пропущен (skipped).

$ pytest test_fixture_marks.py -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 3 items

test_fixture_marks.py::test_data[0] PASSED                           [ 33%]
test_fixture_marks.py::test_data[1] PASSED                           [ 66%]
test_fixture_marks.py::test_data[2] SKIPPED                          [100%]

======================= 2 passed, 1 skipped in 0.12s =======================

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

Фикстуры могут использоваться не только тестовыми функциями, но и другими фикстурами. Это помогает делать ваши тесты модальными и дает возможность повторного использования фреймворк-зависимых фикстур во множестве проектов. Чтобы продемонстрировать это, давайте расширим предыдущий пример и инициализируем объект app, в котором будем использовать уже объявленный ресурс smtp_connection:

# content of test_appsetup.py

import pytest


class App:
    def __init__(self, smtp_connection):
        self.smtp_connection = smtp_connection


@pytest.fixture(scope="module")
def app(smtp_connection):
    return App(smtp_connection)


def test_smtp_connection_exists(app):
    assert app.smtp_connection

Здесь мы объявляем фикстуру app, которая принимает ранее объявленную фикстуру smtp_connection и создает с ее помощью объект App:

$ pytest -v test_appsetup.py
=========================== 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_appsetup.py::test_smtp_connection_exists[smtp.gmail.com] PASSED [ 50%]
test_appsetup.py::test_smtp_connection_exists[mail.python.org] PASSED [100%]

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

Поскольку фикстура smtp_connection параметризована, тест запустится дважды с разными экземплярами приложения App и соответствующими им серверами smtp. Нам не нужно параметризовать smtp_connection в фикстуре app, так как pytest самостоятельно анализирует граф зависимостей.

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

Автоматическая группировка тестов экземплярами фикстур

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

Следующий пример использует две параметризованные фикстуры, одна из которых имеет уровень модуля, и обе функции вызывают print, чтобы продемонстрировать поток инициализации/завершения.

# content of test_module.py
import pytest


@pytest.fixture(scope="module", params=["mod1", "mod2"])
def modarg(request):
    param = request.param
    print("  SETUP modarg", param)
    yield param
    print("  TEARDOWN modarg", param)


@pytest.fixture(scope="function", params=[1, 2])
def otherarg(request):
    param = request.param
    print("  SETUP otherarg", param)
    yield param
    print("  TEARDOWN otherarg", param)


def test_0(otherarg):
    print("  RUN test0 with otherarg", otherarg)


def test_1(modarg):
    print("  RUN test1 with modarg", modarg)


def test_2(otherarg, modarg):
    print("  RUN test2 with otherarg {} and modarg {}".format(otherarg, modarg))

Давайте запустим код в режиме подробных сообщений (с опцией -v) и посмотрим на вывод:

$ pytest -v -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 -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collecting ... collected 8 items

test_module.py::test_0[1]   SETUP otherarg 1
  RUN test0 with otherarg 1
PASSED  TEARDOWN otherarg 1

test_module.py::test_0[2]   SETUP otherarg 2
  RUN test0 with otherarg 2
PASSED  TEARDOWN otherarg 2

test_module.py::test_1[mod1]   SETUP modarg mod1
  RUN test1 with modarg mod1
PASSED
test_module.py::test_2[mod1-1]   SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod1
PASSED  TEARDOWN otherarg 1

test_module.py::test_2[mod1-2]   SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod1
PASSED  TEARDOWN otherarg 2
test_module.py::test_1[mod2]   TEARDOWN modarg mod1
  SETUP modarg mod2
  RUN test1 with modarg mod2
PASSED

test_module.py::test_2[mod2-1]   SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod2
PASSED  TEARDOWN otherarg 1

test_module.py::test_2[mod2-2]   SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod2
PASSED  TEARDOWN otherarg 2
  TEARDOWN modarg mod2


============================ 8 passed in 0.12s =============================

Вы можете увидеть, что параметризация фикстуры modarg на уровне модуля привела к выполнению тестов в порядке, позволяющем минимизировать «активные» ресурсы. Финализатор фикстуры с параметром mod1 был вызван до инициализация фикстуры с параметром mod2.

Заметьте, что test_0 полностью независим и поэтому был завершен первым. test_1 был выполнен с параметром mod1, потом с тем же параметром был запущен test_2, после этого - test_1 с параметром mod2, последним был запущен test_2``с параметром ``mod2.

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

Использование фикстур в классах, модулях и проектах

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

# content of conftest.py

import os
import shutil
import tempfile

import pytest


@pytest.fixture
def cleandir():
    old_cwd = os.getcwd()
    newpath = tempfile.mkdtemp()
    os.chdir(newpath)
    yield
    os.chdir(old_cwd)
    shutil.rmtree(newpath)

Затем объявим ее использование в тестовом модуле с помощью декортатора usefixtures:

# content of test_setenv.py
import os
import pytest


@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
    def test_cwd_starts_empty(self):
        assert os.listdir(os.getcwd()) == []
        with open("myfile", "w") as f:
            f.write("hello")

    def test_cwd_again_starts_empty(self):
        assert os.listdir(os.getcwd()) == []

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

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

Также можно «прицепить» несколько фикстур сразу

@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test():
    ...

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

pytestmark = pytest.mark.usefixtures("cleandir")

Обратите внимание, что переменная должна называться именно pytestmark; если вы назовете ее, например, foomark, ваша фикстура инициализироваться не будет.

Можно также затребовать вашу фикстуру для всех тестов проекта, указав в «ini»-файле:

# content of pytest.ini
[pytest]
usefixtures = cleandir

Предупреждение

Внимание! Такая маркировка неэффективна для функций-фикстур! Нижеприведенный код не будет работать так, как должен:

@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture():
    ...

На данный момент подобный код не генерирует ошибок или предупреждений, но это планируется исправить, см. #3664.

Фикстуры autouse (автоматическое использование фикстур)

Иногда хочется, чтобы фикстуры вызывались автоматически, без явного указания их в качестве аргумента и без использования декоратора usefixtures . Предположим, у нас есть фикстура, имитирующая базу данных с архитектурой «begin/rollback/commit» и мы хотим автоматически обернуть каждый тестовый метод транзакцией и откатом к начальному состоянию. Вот макет реализации этой идеи:

# content of test_db_transact.py

import pytest


class DB:
    def __init__(self):
        self.intransaction = []

    def begin(self, name):
        self.intransaction.append(name)

    def rollback(self):
        self.intransaction.pop()


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


class TestClass:
    @pytest.fixture(autouse=True)
    def transact(self, request, db):
        db.begin(request.function.__name__)
        yield
        db.rollback()

    def test_method1(self, db):
        assert db.intransaction == ["test_method1"]

    def test_method2(self, db):
        assert db.intransaction == ["test_method2"]

Фикстура transact уровня класса промаркирована autouse=true, и это означает, что все тестовые методы класса будут использовать эту фикстуру без необходимости указывать ее в сигнатуре тестовой функции или использовать на уровне класса декоратор usefixtures.

Запустив, получим два успешно пройденных теста:

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

Вот как работают фикстуры на разных уровнях:

  • «autouse»-фикстуры соблюдают область действия, определенную с помощью параметра scope=: если для фикстуры установлен уровень scope='session' - она будет инициализирована только один раз, при этом неважно, где она определена. scope='class' означает инициализацию один раз для класса и т. д.

  • если «autouse»-фикстура определена в тестовом модуле, то ее будут автоматически использовать все тесты модуля.

  • если «autouse»-фикстура определена в файле conftest.py, то вызывать фикстуру будут все тесты во всех тестовых модулях соответствующей директории.

  • и, наконец (пожалуйста, используйте эту возможность с осторожностью): если вы определяете «autouse»-фикстуру в плагине, она будет вызываться для всех тестов во всех проектах, где установлен этот плагин. Это может быть полезно, если фикстура работает только при определенных настройках (указанных, например, в «ini»-файлах). Такая глобальная фикстура всегда должна быстро определять, нужно ли ей что-либо делать, чтобы избежать дорогостоящего импорта и вычислений.

Что касается приведенной выше фикстуры transact, вы можете захотеть, чтобы она была доступна в вашем проекте, не будучи при этом активной. Классический способ сделать это - поместить ее в файл conftest.py, не применяяя «autouse»:

# content of conftest.py
@pytest.fixture
def transact(request, db):
    db.begin()
    yield
    db.rollback()

И затем, если понадобится, создать тестовый класс, объявив ее использование:

@pytest.mark.usefixtures("transact")
class TestClass:
    def test_method1(self):
        ...

В этом случае фикстуру transact будут использовать все тестовые методы класса TestClass; остальные тесты не будут к ней обращаться, пока вы так же явно не укажете необходимость ее использования.

Переопределение фикстур разного уровня

В больших тестовых наборах вам может понадобиться переопределять глобальные (global) или корневые (root) фкстуры локальными (locally), сохраняя тестовый код читабельным и поддерживаемым.

Переопределение фикстур на уровне каталога (conftest.py)

Допустим, наш проект имеет такую файловую структуру:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        def test_username(username):
            assert username == 'username'

    subfolder/
        __init__.py

        conftest.py
            # content of tests/subfolder/conftest.py
            import pytest

            @pytest.fixture
            def username(username):
                return 'overridden-' + username

        test_something.py
            # content of tests/subfolder/test_something.py
            def test_username(username):
                assert username == 'overridden-username'

Как видите, фикстура с одним и тем же именем может быть переопределена на уровне конкретного подкаталога. При этом «базовая» фикстура доступна в переопределенной (см. пример выше).

Переопределение фикстуры на уровне тестового модуля

Рассмотрим еще одну файловую структуру:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-' + username

        def test_username(username):
            assert username == 'overridden-username'

    test_something_else.py
        # content of tests/test_something_else.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-else-' + username

        def test_username(username):
            assert username == 'overridden-else-username'

Пример показывает, как можно переопределить фикстуру с одним и тем же именем в конкретном тестовом модуле.

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

Возьмем следующую структуру тестов:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

        @pytest.fixture
        def other_username(username):
            return 'other-' + username

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.mark.parametrize('username', ['directly-overridden-username'])
        def test_username(username):
            assert username == 'directly-overridden-username'

        @pytest.mark.parametrize('username', ['directly-overridden-username-other'])
        def test_username_other(other_username):
            assert other_username == 'other-directly-overridden-username-other'

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

Замена параметризованной фикстуры непараметризованной и наоборот

Расммотрим такую структуру:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture(params=['one', 'two', 'three'])
        def parametrized_username(request):
            return request.param

        @pytest.fixture
        def non_parametrized_username(request):
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def parametrized_username():
            return 'overridden-username'

        @pytest.fixture(params=['one', 'two', 'three'])
        def non_parametrized_username(request):
            return request.param

        def test_username(parametrized_username):
            assert parametrized_username == 'overridden-username'

        def test_parametrized_username(non_parametrized_username):
            assert non_parametrized_username in ['one', 'two', 'three']

    test_something_else.py
        # content of tests/test_something_else.py
        def test_username(parametrized_username):
            assert parametrized_username in ['one', 'two', 'three']

        def test_username(non_parametrized_username):
            assert non_parametrized_username == 'username'

Здесь параметризованная фикстура заменяется непараметризованной и наоборот в рамках конкретного тестового модуля. То же самое можно проделывать и для тестовых каталогов/подкаталогов.