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

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

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

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

Наши тесты и технологический стек

Интеграционные тесты мы пишем в основном на Python, а тестовый фреймворк строится поверх Pytest. У нас порядка тысячи end-to-end тестов c пользовательскими сценариями. Это могут быть как простые сценарии, например, создание логических сущностей в терминах СХД, так и сложные — полное обновление системы под нагрузкой или аварийное переключение.

В качестве CI мы используем Jenkins, а для репортинга — Allure. Храним тесты в системе собственной разработки TestY.

Почитать об отличиях TestY от других систем управления тестами и преимуществах нашей TMS для команд любого размера можно в статье моего коллеги Дмитрия Ткача. Кстати, решение выложено в open source, и вы можете переиспользовать его для своих задач.

Плагин Version markers

Плагин Version markers мы используем для запуска тестов на конкретной версии продукта.

Проблема

У систем хранения данных, которые мы тестируем, всегда есть версии, например:

PRODUCT_VERSION = 2.2.0-XXX, 2.3.0-YYY, 2.5.0-KKK

Цифрами представлена маркетинговая версия. Она говорит о наличии определенных фич в продукте. Буквы показывают инженерную версию. Это просто счетчик, который меняется каждый раз, когда разработчики заливают изменения в мастер.

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

Интеграционные тесты 

Допустим, у нас есть три теста. Первый — test_one() — работает для всех трех версий продукта. Второй — test_two() — работает на версиях 2.2.0 и 2.3.0, а вот на версии 2.5.0 — нет, потому что в ней поведение продукта изменилось. А еще есть тест test_three(), который должен запускаться только на версии 2.5.0, потому что покрывает разработанный в ней новый функционал. 

Тест

2.2.0

2.3.0

2.5.0

test_one ()

test_two ()

test_three ()

Как понять, должны тесты запускаться для конкретной версии продукта или нет?   

Требования 

Сформулируем требования к плагину:  

  • Реализовать возможность не запускать некоторые тесты на определенных версиях продукта.

  • Реализовать возможность указать максимальную и минимальную версию продукта, на которых должен запускаться тест.

  • Написать реализацию через декоратор @pytest.mark.

  • Не добавлять в Allure-отчет тесты, если они не запускались на текущей версии продукта.

Остановлюсь чуть подробнее на последнем пункте. Когда я рассказывал про Pytest-хуки в первой части статьи, то показывал, что мы можем исключить какую-то комбинацию тестов в runtime с помощью pytest.mark.skip. Аналогичный подход мы используем для исключения тестов, на падение которых заведен баг в продукте, — скипаем их с номером Jira-тикета.

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

Реализация 

Для начала нужно понять, на какой фазе жизненного цикла Pytest мы хотим изменить поведение фреймворка. Нам нужно, чтобы отдельные тесты не запускались на определенной версии продукта. Не запускались — значит, фильтровались. А за фильтрацию тестов отвечает фаза Сollecting.

Фазы жизненного цикла Pytest для тех, кто не читал первую часть статьи

56754dea764990f60712955c11e6feb0.jpg

Фазы работы Pytest:

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

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

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

  4. Reporting (создание отчета). Здесь формируются отчеты о запуске, которые позволяют посмотреть результат выполнения тестов.

Чтобы отфильтровать тесты, нужен какой-то признак. Мы добавляем его в хук-функции pytest_itemcollected. Pytest вызывает ее, когда находит новый тест и собирается добавить его в коллекцию для запуска.

def pytest_itemcollected(item: pytest.Item):
	item.ignore = False

	image_version_mark = item.get_closest_marker('image_version')
	if image_version_mark:
		item.ignore = handle_image_version_mark(item, image_version_mark)

Здесь мы добавляем атрибут ignore со значением False как признак того, что тест нужно выполнить.

Мы решили использовать маркеры Pytest для реализации данного функционала по причине их удобства: благодаря декоратору @pytest.mark в коде наглядно представлены как сам тест, так и наложенные на него ограничения. Pytest предоставляет отличный API для взаимодействия с маркерами. К примеру, используя метод get_closest_marker('image_version'), мы можем получить доступ к маркеру как к объекту, включая заданные для этого маркера параметры.

Мы получаем этот маркер (если он есть у теста), его параметры, версию продукта, на которой сейчас запущены тесты, и на основании этой информации принимаем решение, нужно ли запускать тесты. Если запускать тест не нужно, то выставляем значение атрибута ignoreв значение True. Сам код функции handle_image_version_mark, который сравнивает версии по определенному принципу, я не буду приводить в статье из-за его большого объема. 

Итак, мы получили признак, по которому можем отфильтровать тесты. Сделать это поможет хук-функция pytest_collection_modifyitems:

def pytest_collection_modifyitems(config, items: list[pytest.Item]):
	selected = []
	deselected = []
	for item in items:
		if item.ignore:
		    deselected.append(item)
		    continue
		selected.append(item)
	items[:] = selected

В качестве параметров данная функция получает список items. Мы используем items для принятия решения о фильтрации по значению атрибута. True означает, что тест должен быть отфильтрован, остальные тесты будут добавлены в коллекцию selected.

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

Отдельно стоит упомянуть о списке deselected. deselected используется в контексте функции pytest_collection_modifyitems для сбора и отслеживания тестов, которые были исключены из текущего прогона. При работе с Pytest, особенно когда применяются фильтры для определения тестов, которые должны быть выполнены, возникает потребность не только в выборе тестов для выполнения (что собирается в список selected), но и в явной идентификации тестов, которые были отфильтрованы или исключены по определённым критериям. Информация об отфильтрованных тестах может использоваться другими инструментами или плагинами Pytest для дополнительной обработки, например, генерации отчетов.

Это весь код плагина, который нужно было реализовать в хук-функциях. 

Чтобы ваш внешний плагин начал работать, его нужно установить локально. Мы храним плагины в отдельных репозиториях, поэтому установка делается через стандартный pip install путь_до_плагина. Для этого необходимо добавить setup.py и прописать entry_points.

Получившиеся маркеры будем использовать вот так:

import pytest
import os

os.environ['PRODUCT_VERSION'] = '2.5.0-123'

@pytest.mark.image_version(only='2.5')
def test_is_executed():
	return

@pytest.mark.image_version(only=['2.0', '2.2'])
def test_is_ignored():
	return

У нас есть версия продукта 2.5.0–123. На один из тестов мы устанавливаем декоратор image_version с параметром only=’2.5’. Это значит, что данный тест должен запускаться только на версии продукта 2.5. На другой тест мы можем повесить тот же маркер с параметром only и передать в качестве аргумента список — [’2.0’, ‘2.2’]. Он говорит о том, что тест должен запускаться на версиях 2.0 или 2.2. Поскольку мы зафиксировали версию 2.5, второй тест запускаться не будет.

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

Для парсинга версии продукта мы использовали модульversion из пакета packaging:

>>> from packaging import version
>>> version.parse('2.5.0-123').major

version берет строковое представление версии, парсит ее и возвращает нам в виде объекта. Это дает возможность посмотреть major-версию, minor-версию, префикс, а также предоставляет удобный механизм сравнения версий. Саму же версию продукта мы берем из переменной окружения, которую прокидывает CI. И код плагина прекрасно работает, когда мы запускаем тесты для той версии продукта, которую предполагаем там увидеть. 

В нашем CI находится огромное количество джобов. Мы протестировали плагин Version markers на всех джобах, которые знали. Но пропустили одну, которую используют разработчики, когда делают PR в основную ветку. Как оказалось, в ней к версии продукта добавляются еще PR id и хэш коммита, например:

>>> version.parse('2.5.0-123-PR-4859-80011dd03d93').major

К сожалению, модуль version не может разобрать такую версию и генерирует исключение InvalidVersion. И тут возникает еще один интересный момент — в хук-функциях из фазы Collecting нельзя генерировать исключения. Когда исключение возникает, Pytest падает. Никакие тесты при этом не запускаются. Так мы немного подпортили день разработчикам, пришлось откатить свои изменения во фреймворке и искать проблему.

Эта ситуация приводит нас к полезному выводу: на плагины очень важно писать тесты.

Покрытие тестами

У Pytest есть отличный механизм для написания тестов — Pytester. Это внутренний плагин самого фреймворка. По ссылкам можно посмотреть исходники его кода и написанные на Pytester тесты.

Давайте посмотрим, как применять этот механизм. Допустим, у нашего плагина структура как на картинке ниже: код плагина находится в файле plugin.py, а тесты — в tests.py:  

d93c7251e4c48fbbc601d9ccec04ce13.jpg

По умолчанию плагин Pytester неактивен. Чтобы его активировать, нужно добавить файл conftest.py со следующим кодом:  

pytest_plugins = ["pytester”]

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

Давайте напишем первый тест, который проверяет, что интеграционный тест успешно запускается, если на нем нет никаких маркеров:  

def test_without_marks(pytester):
	pytester.makepyfile(
		"""
		def test_without_marks():
		    return 'test_without_marks must be executed' 
		""")
	result = pytester.runpytest('-s')
	assert result.ret == pytest.ExitCode.OK
	result.stdout.fnmatch_lines(['collected 1 item'])
	result.assert_outcomes(passed=1)

Фикстура pytester возвращает объект класса Pytester. С помощью метода makepyfile мы создаем временный тестовый файл, содержащий один тест test_without_marks. Этот метод автоматически создает файл во временной директории, что избавляет нас от необходимости вручную управлять файлами и путями.

Далее мы запускаем наш тест test_without_marksс помощью метода runpytest('-s'). Флаг -s позволяет выводить print-сообщения и другой вывод в консоль во время тестирования, что полезно для отладки и проверки вывода тестов. Причем результат запуска будет возвращен в виде объекта. У этого объекта много полезных методов, которые позволяют легко выполнить различные проверки. Например, мы можем убедиться, что запуск тестов был успешным через код возврата — ExitCode.OK. Мы можем найти какие-то строки, которые ожидаем увидеть в консоли, например, что был найден один тест и он успешно прошел. 

Напишем еще один тест. В нем проверим, что декоратор для версий работает:

@pytest.mark.parametrize('env_version', ('2.5.0-123', '2.5.0-123-PR-4859-80011dd03d93', '2.2.0-256'))
def test_image_version(pytester, env_version):
    os.environ['PRODUCT_VERSION'] = env_version

    pytester.makepyfile(
        """
        import pytest

        @pytest.mark.image_version(only='2.5')
        def test_image_version_only_str_minor():
              return

        @pytest.mark.image_version(only='2.5.0')
        def test_image_version_only_str_post():
            return
        
        @pytest.mark.image_version(only='2.8')
        def test_image_version_only_str_other():
            return
        """)

    result = pytester.runpytest('-s', '--collect-only')
    tests = []
    selected, deselected = 0, 3
    if env_version in ('2.5.0-123', '2.5.0-123-PR-4859-80011dd03d93'):
        tests = [
            'test_image_version_only_str_minor',
            'test_image_version_only_str_post',
        ]
        selected, deselected = 2, 1

    for test in tests:
        result.stdout.fnmatch_lines(f'??')

    collected = selected + deselected
    result.stdout.fnmatch_lines(f'collected {collected} items / {deselected} deselected / {selected} selected')

Pytest позволяет использовать различные фичи, например, parametrize. Мы можем параметризировать тест разными версиями и после этого прокидывать их в переменные окружения. Как и в предыдущем случае, мы можем запустить данный тест, используя pytester, получить результат и выполнить необходимые проверки.

Кроме того, с помощью Pytester можно проверить нужные нам исключения. Например, у нас в логике работы плагина есть проверка, что минимальная версия не может быть больше максимальной:

def test_raise_runtime(pytester):
	os.environ['PRODUCT_VERSION'] = '1.1'

	pytester.makepyfile(
		"""
		import pytest
			
		@pytest.mark.image_version(min='2.2', max='2.1')
		def test_one():
	        return
		""")
	
    result = pytester.runpytest('-s', '--collect-only')
    pattern = 'INTERNALERROR> RuntimeError: "min" version is greater than "max"'
    result.stdout.re_match_lines([pattern])

Итого Pytester — достаточно удобный и сравнительно несложный механизм тестирования плагинов.

Плагин Mongo saver

Version markers — относительно простой плагин, и написать к нему тесты тоже было нетрудно. Теперь давайте рассмотрим второй плагин с более сложной реализацией тестов — Mongo saver. Мы используем его для сохранения метаинформации о тестах в базу данных Mongo. 

Проблема

Для репортинга мы используем Allure. Из-за особенностей запуска наших тестов в нем отображается не совсем корректная статистика. Как я уже говорил, у нас порядка тысячи end-to-end тестов, и они бегут достаточно долго. Мы запускаем тесты параллельно, чтобы сократить время получения результатов, но делаем это не силами Pytest, а с помощью CI (это связано с особенностью e2e-тестов на СХД). На схеме ниже я упрощенно изобразил pipeline для запуска тестов, он позволит понять основную проблему:

Pipeline для запуска тестов.

Pipeline для запуска тестов.

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

Волны могут запускаться в разное время — это происходит по наличию свободных CI-агентов. По этой причине статистика в Allure-отчете начинает плавать: она не отражает корректную информацию. Кроме того, мы не можем отсортировать все тесты по времени выполнения. Это возможно сделать в рамках одного класса, но сквозная аналитика от большего к меньшему просто не работает. И последний неприятный момент — результат запуска мы видим только после формирования Allure-отчета, а это как минимум час. То есть о том, что какой-то тест упал, мы узнаем примерно через 60 минут.

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

Требования 

Формализуем требования. Мы хотим получить:  

  • Корректную статистику времени прохождения тестов для их оптимизации и построения аналитики.

  • Дополнительную метаинформацию по тестам. Например, Jira-тикет, если тест был исключен из-за проблемы. Это позволит наглядно соотнести тикеты с количеством заблокированных тестов. 

  • Сохранение информации сразу после прохождения теста, а не после прогона всех волн. 

Реализация 

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

В Pytest запуск тестов происходит через вызов хук-функции pytest_runtest_protocol. При этом для каждого теста будут выполнены этапы setup, call и teardown. Каждый из этих этапов разбивается на определенный набор хуков:  

  • для setup вызывается хук pytest_runtest_setup, 

  • для call — pytest_runtest_call,

  • для teardown — pytest_runtest_teardown

Три следующих хука одинаковы для всех трех этапов. Сначала вызывается pytest_runtest_makereport, в котором можно получить информацию о результате выполнения этапа. За логирование отвечает хук pytest_runtest_logreport, за обработку исключений — pytest_exception_interact.

Три этапа запуска теста в Pytest и их хуки.

Три этапа запуска теста в Pytest и их хуки.

Для решения нашей задачи можно использовать хук pytest_runtest_makereport. Он вызывается во всех трех этапах, но нам интересен только teardown, после которого тест завершается. Как внутри самой хук-функции понять, что она была вызвана именно из teardown?  

@pytest.hookompl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(self, item, call):
    #execute all other hooks to obtain the report object
    outcome = yield
    rep = outcome.get_result() # got TestReport object here

    if rep.when != 'teardown':
        return
    #save test result using REST call 

Параметр hookwrapper=True позволяет хук-функции оборачивать другие хук-функции. В данном случае другими будут дефолтные функции самого Pytest. С помощью ключевого слова yield и метода get_result() мы получаем результат выполнения этих хук-функций в виде объекта TestReport. В этом объекте есть признак того, из какого этапа он был вызван. 

Признак называется when, и с его помощью мы можем определить, что данная хук-функция была вызвана из этапа teardown. Если вызов был из teardown, то мы сохраняем информацию о прохождении теста с помощью POST-запроса через REST-сервис в Mongo.

Откуда взялся REST-сервис?

Мы решили сохранять данные в Mongo не напрямую из плагина, а через REST-сервис. Мы не были уверены, что Mongo подойдет нам по всем параметрам, и абстрагировали ее от плагина, чтобы иметь возможность в любой момент заменить базу данных. Поэтому код нашего плагина через POST-запросы общается с REST-сервисом, а уже REST-сервис сохраняет информацию в кластер Mongo.

2837f59da968df77d5cc3d0fa5f3a4e0.jpg

Итак, мы отправляем метаинформацию о тесте в хук-функции pytest_runtest_makereport. Но нам нужно понимать, когда все тесты в рамках волны добегут, для того, чтобы пометить эти результаты как доступные для аналитики. Чтобы понять, когда все тесты добежали, мы используем хук-функцию pytest_sessionfinish:

@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(self, session, exitstatus):
    """Save run(wave) data at the end of session."""
    if session.config.option.collectonly:
        return
    test_run_data = {
        'run_id': self.run_id,
        'result': RunResult.PASS.value if exitstatus == 0 else RunResult.FAIL.value,
        'finished_time': datetime.now().isoformat(),
        'wave_name': self.wave_name
    }

    self.rest_client.save_run(test_run_data)

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

Тестирование плагина

В случае Mongo saver недостаточно просто проверить результат запуска тестов, потому что на самом деле мы отправляем информацию с помощью REST-запросов. Нам нужно проверить, что REST-запрос был выполнен с корректными параметрами. Также мы помним, что в хук-функциях нельзя вызывать исключения. Поэтому нужно быть уверенными, что если по какой-то причине POST-запрос упадет, то тесты не сломаются.

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

Pytester может запустить тесты, но как проверить, что REST был вызван с правильными параметрами? На помощь придет функционал Pytest, который называется Monkeypatch. С помощью него мы будем временно модифицировать библиотеку requestsво время тестов для того, чтобы выполнить нашу собственную обработку REST-запросов.

@pytest.fixture()
def mock_requests(self, monkeypatch, status_code) -> list[MockRequest]:
	requests_sequence = []
	
	def mock_request(method, url, **kwargs):
		mocked_request = MockRequest(method, url, kwargs, status_code)
		requests_sequence.append(mocked_request)
		if status_code == 'imitate_exception':
			raise requests.ConnectionError
		return mocked_request
	monkeypatch.setattr(requests.api, 'request', mock_request)
	yield requests_sequence

В данной фикстуре мы подменяем все вызовы из requests на наш обработчик mock_request. Он возвращает объекты класса MockRequest, которые мы сохраняем в список request_sequence. Именно в список, потому что порядок запросов тут важен. 

Дополнительно в нашем обработчике мы можем эмулировать исключения. Например, если status_code равен 'imitate_exception', то мы вызываем исключение ConnectionError. Так мы сможем проверить поведение плагина в случае, если REST-сервис перестал отвечать. 

Написанная нами фикстура будет возвращать список всех REST-запросов, которые были вызваны плагином. Теперь создадим первый позитивный тест, который проверит, что мы:

  • запускаем один тест, и он успешно проходит,  

  • выполняем REST-запросы, и в них есть все ожидаемые параметры. 

Нам понадобятся фикстуры mock_requests и pytester:

@pytest.mark.parametrize('status_code', (200, 202, 400, 'imitate_exception'))
def test_one_passed_test(self, mock_requests, pytester: pytest.Pytester, request: pytest.FixtureRequest, status_code):
	session_result, save_test_request, save_run_request = self._test_body(pytester, request, mock_requests)
	
    session_result.assert_outcomes(passed=1)

    assert session_result.ret == pytest.ExitCode.OK
    assert json.loads(save_test_request.kwargs['data'])['result'] == 'PASS'
    assert json.loads(save_run_request.kwargs['data'])['result'] == 'PASS'

В коде мы вызываем функцию test_body, давайте посмотрим на нее:

def _test_body(self, pytester: pytest.Pytester, request: pytest.FixtureRequest, requests_seq: list[MockRequest]):
	fn = f'{request.node.originalname}.py'  #file name
	pytester.copy_example(f'{self.__class__, __name__}/conftest.py')
	pytester.copy_example(f'{self.__class__, __name__}/{fn}')
	session_result = pytester.runpytest('-s', fn)

	save_test_request: MockRequest = requests_seq[0]
	save_run_request: MockRequest = requests_seq[1]

	return session_result, save_test_request, save_run_request

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

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

  • о результате прохождения теста,  

  • об окончании волны.

Всю эту информацию мы возвращаем обратно в наш тест. 

> Приведу код позитивного теста еще раз

@pytest.mark.parametrize('status_code', (200, 202, 400, 'imitate_exception'))
def test_one_passed_test(self, mock_requests, pytester, request: pytest.FixtureRequest, status_code):
	session_result, save_test_request, save_run_request = self._test_body(pytester, request, mock_requests)
	
    session_result.assert_outcomes(passed=1)

    assert session_result.ret == pytest.ExitCode.OK
	assert json.loads(save_test_request.kwargs['data'])['result'] == 'PASS'
	assert json.loads(save_run_request.kwargs['data'])['result'] == 'PASS'

Метод _test_body вернул нам данные, и теперь можно написать нужные проверки. Во-первых, мы вернули сам объект результата выполнения тестов. Можно проверить, что тест был найден и результат запуска успешен — Exitcode.OK. И поскольку мы вернули результат выполнения запросов, то можем посмотреть, что у них внутри. С помощью json.loads() мы загружаем их и проверяем, что параметры ровно те, которые мы ожидаем. 

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

@pytest.mark.parametrize('status_code', (200, 202, 400, 'imitate_exception'))
def test_one_failed_test(self, mock_requests, pytester, request: pytest.FixtureRequest, status_code):
    session_result, save_test_request, save_run_request = self._test_body(pytester, request, mock_requests)

    session_result.assert_outcomes(failed=1)

    assert session_result.ret == pytest.ExitCode.TESTS_FAILED
    assert json.loads(save_test_request.kwargs['data'])['result'] == 'FAIL'
    assert json.loads(save_run_request.kwargs['data'])['result'] == 'FAIL'

На этом с примерами плагинов все. Хочу отметить, что Mongo saver — более безопасный плагин, чем Version markers. Его включение или отключение никак не затрагивает тесты: мы просто либо получим сохраненную информацию в Mongo, либо нет.

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

Когда выносить или не выносить код в плагин

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

Если код не зависит от тестов и фреймворка, стоит выделить его в плагин. Это сделает код вашего фреймворка чище за счет того, что в его кодовой базе не будет лишнего. Чистый код — это всегда хорошо.

Если же избежать связи кода с тестами и фреймворком не получается, то логично оставить его во фреймворке. На примере нашего плагина Version markers вы видели, что при выносе такого кода могут возникнуть ненужные побочные эффекты, которые могут повлиять на работу команды. 

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

Если же код сложно покрыть тестами на уровне фреймворка, то имеет смысл вынести его в плагин. Тесты — это сильная сторона плагинов благодаря Pytester и механизмам Monkey patching. 

Что делать, если вынесение кода в плагин приводит к неявному поведению фреймворка? Для начала определимся, что такое это «неявное поведение». Допустим, вы написали функционал, который относится к фазе Collecting и фильтрует тесты по какому-то правилу. Вы вынесли код в плагин и помните о нем. Но высок шанс, что через полгода о существовании плагина вы уже забудете. В команду придут новые люди, чьи тесты будут фильтроваться по неведомой причине. Это и есть неявное поведение. Здесь вспоминается Zen of Python: явное лучше неявного. Подобный код лучше оставлять во фреймворке.

И последний признак, который мы проверили на практике, — если функционал относится к фазе создания отчетов, то, скорее всего, на него можно написать полноценный плагин. Это связано с тем, что Reporting-фаза стоит в самом конце жизненного цикла Pytest. Как правило, она не связана с тестами, что позволяет достаточно легко вынести код в плагин. 

Вы можете сказать, что признаки выше немного капитанские. Но жизнь обычно не настолько бинарна, и четкое понимание будет не всегда. Вспомните пример плагина Version markers, код которого тесно связан с тестами. По второму признаку мы должны были оставить его во фреймворке. Но при этом написать тесты на уровне самого фреймворка было сложно, и по этому признаку код должен уйти в плагин. Вы не раз столкнетесь с подобными противоречивыми условиями. И тут каждый выбирает уже сам, выносить код в плагин или оставить во фреймворке.

Признак

Плагин

Фреймворк

Код не зависит от тестов и фреймворка

Код связан с тестами и фреймворком

Легко покрыть тестами на уровне фреймворка

Сложно покрыть тестами на уровне фреймворка

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

Функционал относится к фазе создания отчетов

Краткие выводы

  • Pytest предоставляет несколько возможностей для расширения и изменения поведения — хуки и плагины.

  • Плагины — это возможность создавать «кусочки» законченного функционала, меняющие поведение вашего фреймворка.

  • Плагины позволяют делать код фреймворка чище за счет выноса их кода из кодовой базы.

  • В идеале плагин не должен зависеть от фреймворка и наоборот. Если вы так делаете, вам нужно понимать, к чему это может привести, к каким последствием и почему вы так делаете. Одной из причин может быть желание написать больше качественных тестов, потому что тесты — сильная сторона плагинов.

  • Не забывайте писать тесты для ваших плагинов.

Удачного всем кодинга!

© Habrahabr.ru