Расширяем тестовый фреймворк с помощью Pytest-плагинов. Часть 1: теория
Всем привет! Меня зовут Александр Волков, я занимаюсь интеграционным и компонентным тестированием в YADRO. В числе продуктов компании есть системы хранения данных и, говоря о тестировании, я буду иметь в виду в первую очередь работу с ними. Однако описанные подходы пригодятся всем, кто ищет инструменты для создания тестового фреймворка и расширения его возможностей под свои задачи.
Я расскажу, как можно разрабатывать свои плагины для тестового фреймворка, построенного поверх Pytest. Для удобства чтения статья разделена на две части. В первой рассмотрю минимально необходимую теорию: фазы работы Pytest, а также пользу фикстур, маркеров и хуков. Во второй части перейду к практике: разберу два примера реализации плагинов из нашего фреймворка и затрону вопрос о том, когда стоит выносить код в отдельный плагин.
Краткий обзор Pytest
Pytest — это популярный фреймворк с открытым исходным кодом для тестирования в Python. Одной из своих ключевых фич проект называет большое количество сторонних плагинов, которые позволяют расширить базовый функционал.
По своей сути Pytest — это test runner, который позволяет найти и запустить тесты, а также получить результат их выполнения. Работу фреймворка можно условно представить в виде схемы:
Можно выделить четыре основные фазы работы Pytest:
Initializing (инициализация Pytest). В этой фазе создаются внутренние объекты Pytest, например,
config
илиsession
, происходит регистрация плагинов и много чего еще.Collecting (поиск тестов). Здесь фреймворк находит тесты, генерирует и фильтрует их, если нужно.
Running test (запуск тестов). В этой фазе выполняется протокол запуска для каждого теста.
Reporting (создание отчета). Здесь формируются отчеты о запуске, которые позволяют посмотреть результат выполнения тестов.
Чтобы перейти к разбору практических примеров, нам нужно разобраться с четырьмя концепциями из мира Pytest: фикстурами, маркерами, хуками и плагинами.
Фикстуры
Pytest fixtures (фикстуры) — это мощный инструмент для настройки предварительных условий для тестов и очистки после их выполнения. Также фикстуры могут быть использованы в подготовке данных для тестов.
Фикстуры в Pytest определяются с помощью декоратора @pytest.fixture
, и их можно затем внедрять в тестовые функции как аргументы. Это улучшает модульность кода, упрощает повторное использование настроек тестов и делает тесты более читаемыми и легкими для поддержки.
Давайте разберем небольшой пример создания и использования фикстур:
@pytest.fixture(scope='session')
def create_resource():
resource = Resource(name='test_resourse', size='100G') #setup
yield resource
resource.delete() #teardown
def test_resource(create_resourse: Resource):
create_resource.fill_resource_urand()
Мы создали фикстуру create_resource
и использовали ее в тесте test_resource
, передав название фикстуры как аргумент. При этом код фикстуры, расположенный до ключевого слова yield
, будет выполнен перед тестом — это аналог этапа setup. А код после yield
будет выполнен по завершению теста, вне зависимости от его результата — это аналог этапа teardown.
Ключевое слово yield
позволяет передавать в тесты различные данные, например, структуры данных или объекты. В примере выше мы передаем в тест объект класса Resource
, который сможем использовать в тесте в дальнейшем, например для того, чтобы получить его размер или проверить состояние в системе.
Маркеры
Pytest marks (маркеры) — это аналог тегов в других языках программирования или фреймворках, они предоставляют удобный способ классифицировать и фильтровать тесты. С помощью декоратора @pytest.mark
можно добавить произвольные маркеры к тесту и после запустить в Pytest тесты с конкретным маркером или их комбинацией.
@pytest.mark.cool_test
def test_with_mark():
...
Однако маркеры в Pytest имеют более мощный функционал, чем просто теги. В маркер можно передавать именованные аргументы, например, параметр version
со значением ‘1.2.3’
:
@pytest.mark.cool_test(version='1.2.3')
def test_with_mark_and_param():
...
Также в Pytest есть несколько встроенных маркеров, например, skip
, который говорит фреймворку исключить из запуска конкретный тест:
@pytest.mark.skip(reason='I do not like this test')
def test_with_mark_and_param():
...
По сути, @pytest.mark
позволяет передать некие метаданные в ваши тесты. Это свойство мы будем использовать при реализации одного из плагинов во второй части статьи.
Хуки
По мере роста тестового фреймворка его нередко нужно менять или расширять. Нам пришлось:
передавать опции командной строки, меняющие поведение тестов,
фильтровать тесты, которые не подходят для конкретной конфигурации системы,
сохранять результат каждого теста в базу данных сразу после его выполнения,
формировать отчет, который можно загружать в систему управления тестами.
Этих возможностей нет в Pytest. Но их можно добавить, используя встроенный механизм хуков.
Pytest hooks (хуки) — это функции с определенным именем и сигнатурой. Их имя начинается на pytest_
, и они должны быть реализованы в файле conftest.py
либо в плагине. Все встроенные в Pytest хук-функции можно найти по ссылке. Их достаточно много, и они тесно связаны с жизненным циклом самого фреймворка:
Группы хук-функций соответствуют фазам работы Pytest. Если мы хотим изменить поведение тестового фреймворка, то необходимо четко понимать, на какой фазе мы планируем это сделать. Дальше нужно найти в документации подходящую хук-функцию и добавить ее реализацию в conftest-файл или в плагин. После этого Pytest в нужный момент вызовет данную функцию, соответственно отработает код, и поведение самого фреймворка изменится.
Покажу для примера хук-функции, которые мы используем в нашем фреймворке. Разберем два кейса:
Автоматическое исключение тестов, которые не подходят по конфигурации системы.
Добавление ссылки на логи системы в Allure-отчет для каждого теста.
Исключаем тесты из выполнения
Допустим, у нас есть параметризованный тест, который создает определенную файловую систему и запускает в нее I/O-нагрузку. Среди этих файловых систем есть btrfs, поддержку которой исключили из RHEL-систем, например, в отдельных версиях CentOS или Red Hat Linux. То есть мы заранее знаем, что тест не может быть выполнен в этих операционных системах. Соответственно, мы хотим исключить из запуска тесты с комбинацией btrfs и определенной ОС. Реализовать это поможет хук-функция pytest_collection_modifyitems
:
def pytest_collection_modifyitems(items: list[Item]):
not_supported_os = 'centos-8', 'centos-8.1', 'rhel-8', 'rhel-8.1'
for test_case in items:
if 'test_fs' in test_case.nodeid:
fs = test_case.callspec.params.get('fs')
os = test_case.callspec.params.get('os')
if fs == 'btrfs' and os in not_supported_os:
reason = f'btrfs is not supported in {not_supported_os}'
test_case.add_marker(pytest.mark.skip(reason=reason)
Обратите внимание на сигнатуру: функция принимает в качестве параметра список Items
. Item
в терминах Pytest — это объект, который описывает тест. Он содержит имя и аргументы теста, маркеры и много другой информации.
Получив аргументы теста, мы можем проверить, содержит ли тест нужную нам комбинацию файловой системы (fs='btrfs'
) и операционной системы (os in not_supported_os
). Если это так, то исключаем тест из данного запуска. Чтобы исключить тест, мы добавляем к нему маркер skip
. В Allure-отчете тест будет отображаться как пропущенный с указанием причины btrfs is not supported
, которую мы указали в коде:
Добавляем ссылку на логи в Allure-отчет
Добавить в отчет ссылку на логи можно с помощью хук-функции pytest_runtest_setup
. Pytest гарантирует, что данная функция вызывается перед выполнением теста.
def pytest_runtest_setup(item: Item):
job_name = os.getenv('SYS_TEST_JOB_NAME')
wave_name = os.getenv('WAVE_NAME')
if job_name and wave_name:
logs_link=f'https://somehost.com/ci-logs{job_name}/{wave_name}/'
allure.dynamic.link(logs_link, name='LOGS')
Мы можем сформировать ссылку на логи из переменных окружения и прикрепить ее к отчету с помощью стандартного механизма Allure через allure.dynamic.link
.
Те, кто давно использует Pytest, могут сказать, что тот же функционал можно реализовать с помощью фикстур. Например, добавлять ссылку в setup. И это правда, но если вы решаете подобную задачу через фикстуру, то ее нужно будет прописывать в каждый тест либо использовать параметры autouse, у которых есть свои нюансы. Применяя хук-функции, мы перекладываем всю работу на Pytest. Это удобно.
Плагины
Мы разобрались с хук-функциями, теперь можно перейти к плагинам. Для себя я формулирую понятие плагина так:
Плагин — это код, который содержит одну или несколько хук-функций и решает одну конкретную задачу. При этом его код никак не связан с тестами.
Почему так: во-первых, правила проектирования говорят, что single responsibility — это хорошо. Во-вторых, здорово, если включение или отключение плагина никак не затрагивают ваши тесты. Когда появляется связь между кодом тестов и плагина, могут возникнуть неприятные нюансы, пример которых я приведу во второй части статьи.
А еще код плагина должен быть покрыт тестами, в Pytest для этого есть прекрасный механизм Pytester.
Код Pytest-плагина может находиться в разных местах:
Встроенные плагины самого Pytest живут во внутренней директории
_pytest
. Из коробки Pytest предоставляет больше 30 плагинов, а некоторые его опции, например,setup-only
,setup-show
илиsetup-plan
тоже реализованы в виде плагинов.Внешние плагины устанавливаются как обычные модули Python — через
pip install имя_плагина
. По ссылке вы найдете масштабную экосистему внешних плагинов, больше 1 000 штук. Они позволят найти решения практически для всех задач.Хук-функции в conftest.py-файле Pytest считает локальным плагином. Про это стоит знать и помнить.
С высокой вероятностью разные плагины в тестовом фреймворке будут использовать одни и те же хук-функции. Что при этом произойдет? Какая из функций запустится первой, какая — второй, а какая — последней? Давайте посмотрим на пример реализации.
Допустим, три плагина используют хук-функцию pytest_collection_modifyitems
:
# Plugin 1
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(items):
#will execute as early as possible
...
# Plugin 2
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(items):
#will execute as late as possible
...
# Plugin 3
@pytest.hookimpl(hookwrapper=True)
def pytest_collection_modifyitems(items):
#will execute even before the tryfirst one above
outcome = yield
#will execute after all non-hookwrappers executed
...
В Pytest есть механизм, который подсказывает фреймворку, какую хук-функцию выполнять в какой момент. Для этого используется декоратор@pytest.hookimpl
, который может принимать несколько флагов:
tryfirst = True
говорит о том, что данная хук-функция должна быть выполнена первой,trylast = True
говорит, что хук-функция должна быть выполнена последней,hookwrapper = True
говорит Pytest, что хук-функция будет оборачивать выполнение других хук-функций. Другими словами, она позволяет выполнять дополнительные действия до и после вызова стандартного хука Pytest.
Код из примера выше выполнится в следующем порядке:
Код в третьем плагине, поскольку там стоит флаг
hookwrapper = True
. Причем будет выполнена только часть до ключевого словаyield
.Код из первого плагина с флагом
tryfirst = True
.Код из второго плагина с флагом
trylast = True
.Код третьего плагина, написанный после ключевого слова
yield
.
Кстати, с помощью yield
мы можем также получить результат выполнения других обернутых плагинов.
Все это описано в документации. Добавлю только, что если при такой схеме где-то в глубине одного плагина возникнет исключение, то:
другой плагин может обработать его и упасть, заменив исходное исключение на свое,
будет очень сложно разобраться, какой плагин и почему изначально сгенерировал исключение, особенно если у вас большое количество плагинов.
Пример такой проблемы и поиска ее причин я приводил в своем докладе на митапе по автоматизации тестирования на Python. Если захотите с ним познакомиться, то можно посмотреть небольшой отрывок, ссылка отправит на нужное место в видео. Чтобы подобных ситуаций не возникало:
внимательно смотрите, какие Pytest-плагины подгружаются при прогоне тестов,
заглядывайте в код плагинов, которые используете, чтобы понимать суть их работы,
не допускайте исключений в реализации хук-функций.
Краткие итоги
Pytest — это популярный фреймворк для тестирования с открытым исходным кодом, функционал которого расширять за счет плагинов. Можно выделить четыре фазы работы Pytest: Initializing, Collecting, Running test, Reporting.
Фикстуры в Pytest — это функции, которые позволяют максимально гибко реализовывать этапы setup и teardown для тестов.
Маркеры — это аналог тегов в других языках программирования или фреймворках. Pytest предоставляет механизмы для модификации и работы с ними в runtime.
С фазами работы Pytest неразрывно связаны хуки — функции с определенным именем и сигнатурой. Знание этой связи понадобится для разработки плагинов.
Плагин — это код, который содержит одну или несколько хук-функций и позволяет изменить или расширить функционал Pytest. Для Pytest уже написано множество плагинов, но вы легко можете написать ваш собственный, если не нашли нужный.
На этом с теорией все. Во второй части статьи перейдем к практике и подробно разберем два примера плагинов из нашего тестового фреймворка.