Расширяем тестовый фреймворк с помощью Pytest-плагинов. Часть 1: теория

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

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

Краткий обзор Pytest

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

По своей сути Pytest — это test runner, который позволяет найти и запустить тесты, а также получить результат их выполнения. Работу фреймворка можно условно представить в виде схемы:

7269069c30f575b51cb17875c6264b39.jpg

Можно выделить четыре основные фазы работы Pytest:

  1. Initializing (инициализация Pytest). В этой фазе создаются внутренние объекты Pytest, например, config или session, происходит регистрация плагинов и много чего еще.

  2. Collecting (поиск тестов). Здесь фреймворк находит тесты, генерирует и фильтрует их, если нужно.

  3. Running test (запуск тестов). В этой фазе выполняется протокол запуска для каждого теста.

  4. 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 хук-функции можно найти по ссылке. Их достаточно много, и они тесно связаны с жизненным циклом самого фреймворка:

a8ae019f77f947c26f2258af9f6271d1.jpg

Группы хук-функций соответствуют фазам работы 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, которую мы указали в коде:

3965d8fc0061a59ccf8db4b6382a28a7.png

Добавляем ссылку на логи в 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-плагина может находиться в разных местах:

  1. Встроенные плагины самого Pytest живут во внутренней директории _pytest. Из коробки Pytest предоставляет больше 30 плагинов, а некоторые его опции, например, setup-only, setup-show или setup-plan тоже реализованы в виде плагинов.

  2. Внешние плагины устанавливаются как обычные модули Python — через pip install имя_плагина. По ссылке вы найдете масштабную экосистему внешних плагинов, больше 1 000 штук. Они позволят найти решения практически для всех задач.

  3. Хук-функции в 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.

Код из примера выше выполнится в следующем порядке:

  1. Код в третьем плагине, поскольку там стоит флаг hookwrapper = True. Причем будет выполнена только часть до ключевого слова yield

  2. Код из первого плагина с флагом tryfirst = True

  3. Код из второго плагина с флагом trylast = True

  4. Код третьего плагина, написанный после ключевого слова yield

Кстати, с помощью yield мы можем также получить результат выполнения других обернутых плагинов.

Все это описано в документации. Добавлю только, что если при такой схеме где-то в глубине одного плагина возникнет исключение, то:

  • другой плагин может обработать его и упасть, заменив исходное исключение на свое,  

  • будет очень сложно разобраться, какой плагин и почему изначально сгенерировал исключение, особенно если у вас большое количество плагинов.

Пример такой проблемы и поиска ее причин я приводил в своем докладе на митапе по автоматизации тестирования на Python. Если захотите с ним познакомиться, то можно посмотреть небольшой отрывок, ссылка отправит на нужное место в видео. Чтобы подобных ситуаций не возникало:

  • внимательно смотрите, какие Pytest-плагины подгружаются при прогоне тестов,

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

  • не допускайте исключений в реализации хук-функций. 

Краткие итоги

  1. Pytest — это популярный фреймворк для тестирования с открытым исходным кодом, функционал которого расширять за счет плагинов. Можно выделить четыре фазы работы Pytest: Initializing, Collecting, Running test, Reporting. 

  2. Фикстуры в Pytest — это функции, которые позволяют максимально гибко реализовывать этапы setup и teardown для тестов.

  3. Маркеры — это аналог тегов в других языках программирования или фреймворках. Pytest предоставляет механизмы для модификации и работы с ними в runtime.

  4. С фазами работы Pytest неразрывно связаны хуки — функции с определенным именем и сигнатурой. Знание этой связи понадобится для разработки плагинов.

  5. Плагин — это код, который содержит одну или несколько хук-функций и позволяет изменить или расширить функционал Pytest. Для Pytest уже написано множество плагинов, но вы легко можете написать ваш собственный, если не нашли нужный.

На этом с теорией все. Во второй части статьи перейдем к практике и подробно разберем два примера плагинов из нашего тестового фреймворка.

© Habrahabr.ru