Привет. Меня зовут Ирина и хочу рассказать про так pytest работает под капотом. Это очень вольный перевод этой статьи (на англ.) и мои дополнения основанные на pytest коде. Это одна из цикла статей о pytest. И в это статье мы рассмотрим этап коллекции в pytest.
Введение
Pytest основан на pluggy. Основная единица pytest - pytest плагин. Написан достаточно интересно. Ключевое слово - “капуста” или матрешки. Множество декораторов и адаптеров. Основное взаимодействие в pytest происходит через хуки. Хук это некий этап к которому можно получить доступ к той или иной логики работы. Следуя из названия это некоторые крючки за который можно цепляться вставляя свои заплатки. Начинаются с pytest.
Фикстуры (Fixture) в pytest это некий аналог мока/сетап tear down в unittests. Это некие кусочки кода результаты которых могут быть пере использованы. Сами фикстуры реализованы как плагин.
Как уже говорилось в эта система плагинов полагается на Pluggy. В Pluggy програамма полагается на PluginManager
который управляет сохранения спецификаций хуков регистрацией плагинов и вызовом их. Плагины могут регистрировать сами себя в PluginManager.
Когда хук стартуют они вызывают свои имплементации по умолчанию как LIFO
очередь - самый поздний элемент вызывается раньше всего. Для изменения этого порядка вызова можно применять trylast
or tryfirst
свойства в их имплементациях(пример). По умолчанию возвращается результат от всех имплементаций с исключением случая с как firstresult свойством. В случае свойства firstresult
программа возвращает результат первого не None
результата.
Другое интересное свойство имплементации плагина это hookwrapper
. С помощью этого свойства имплементации будут вести себя как обертки над другими хуками с помощью yield
.
Вдохнули?
Хуки вызываются 3 способами:
Простой способ. С помощью
pluginmanager.hook.HOOK_NAME(**kwargs)
. Хук вызывается как с кварками обычным образом. Возвращает результат вызова хука. Пример - pytest_make_collect_report, хук вызываемый на стадии коллекции для набора айтемов(items) после коллектирования коллекторов. Если вы не знаете что такое стадия коллекции дальше будет объяснение.-
Исторический. С помощью p
luginmanager.hook.HOOK_NAME.call_historic (result_callback, **kwargs)
. Вызывается с kwargs для каждой имплементации для уже зарегистрированного плагина но и для всех будущих регистраций плагинов. Т.е. вновь зарегистрированные плагины будут проходить этот хук.Метод result_callback вызывается для каждого результата. Историческим плагинам доступна вся цепочка вызовов от предыдущих вызов хука.
Интересно что вызов исторического хука не возвращают результата. Применяется для консистентности порядка вызовов и увеличения производительности. Пример исторического хука - pytest_plugin_registered хук вызываемый при регистрации очередного плагина.
Экстра вызов. С помощью
pluginmanager.hook.HOOK_NAME.call_extra(methods, **kwargs)
. Помимо вызова имплементаций хука kwargs временно рассматривает methods как имплементации хука. Пример - pytest_generate_tests, хук который помимо имплементаций добавляет соответствующие методы классов. Вызывается для генерации тестовых вызовов(test invocation) для функций.
Выдохнули? Если что то непонятно то следующие диаграммы помогут понять цепочку вызовов хуков.
Хук спецификации расположены в src/_pytest/hookspec.py и их основые имплементации in src/_pytest как различные модули. Примеры реализации хуков можно посмотреть здесь.
Есть внутренние плагины и локальные плагины и инсталлируемые плагины. Первые - это дефолтные плагины. Локальные плагины которые регистрируют себя conftest файл. Или же инсталлируемые плагины которые также регистрируют себя через conftest файл. Подробнее тут.
Список базовых и дефолтных плагинов:
Pytest фазы можно разделить на 3 этапа:
Стадия коллекции (collection stage). Как следует их названия, на этом этапе происходит в основном создание конфигов, регистрации плагинов и создание самих тестовых вызовов.
Стадия выполнения тестов. (call stage). На этом этапе вызываются фикстуры и выполняются тестовые вызовы, созданные на предыдущих этапах.
Стадия отчета о выполненных тестах (report stage).
В этой статье мы рассмотрим стадию коллекции. На этой стадии:
Pytest перезаписывает
pluginManager
собственным плагин менеджером.Создается конфиг. Этот конфиг нужен для того чтобы того чтобы сохранять конфиги.
Регистрируются плагины.
Создается объект сессии. Сессия нужна как корень собираемых нодов. Выполняет некие начальные настройки с путями в pytest.
Рекурсивно собираются модули и функции.
Собираются описания фикстур как транзитивные замыкания для функций.
Создаются параметризованные вызовы функций.
Конфиги
В качестве приготовления сначала PluginManager
регистрирует себя как плагин.
Далее создается Config. При инициализации конфига вызывается хук pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager), с помощью которого добавляются опции командной строки плагинов.

Config регистрирует себя как плагин и в цикле пробегается по внутренним плагинам для их регистрации.
Сам config._parse это инстанс Parser (определенный in src/_pytest/config/argparsing.py) который под капотом использует python's argparse.ArgumentParser
для парсинга опций.
Далее вызывается хук hook.pytest_load_initial_conftests(config: Config, args: List[str], parser: Parser)
Плагин |
Реализация |
config |
Пытается получить conftest плагины из путей указанных в командной строке. Для каждого, conftest.py в рутовой папке и однажды в этом test* субдиректории вызываются как плагины для каждого, pytest_plugin_registered хук вызывается. |
Следующим шажочком вызывается pytest_plugin_registered хук. Это - исторический хук. Он вызывается для каждого зарегистрированного плагина. Откуда он берется? Он берется из метода заоверайденного метода register PytestPluginManager. Помните? PytestPluginManager
расширяет имплементацию PluginManager
.
Примеры реализаций:
Плагин |
Реализация |
fixture |
Выполняет начальные настройки для фикстур - устанавливает области видимости. |
logger |
Логирует плагины. |
Следующим шагом вызывается pytest_cmdline_main(config: Config) Это firstresult хук. Т. е. он возвращает первый результат реализации хука.
Плагин |
Реализация |
main |
Сначала хук pytest_configure вызывается. Следуя он позволяет плагинам изменять начальную конфигурация. Синглетон-объект session:Session создан и в рамках вызываются хуки pytest_sessionstart, pytest_collection. Можно заметить что session регистрирует себя как плагин. |
pytest_configure(config: Config) Это исторический хук и его цель в конфигурации плагинов самих по себе. Множество встроенных плагинов используют этот хук, включая
lf plugin
,nf plugin
иstepwise plugin
. Мы не будем останавливаться на этих плагинах сейчас. Скажу только, чтоlf plugin
отвечает за запуск только упавших тестов, nf plugin — за запуск только новых тестов.Stepwise
продолжает предыдущие тесты строчки кода где есть упавшие тесты.
Сбор начальных данных
Hook.pytest_sessionstart (session: Session). Хук указывающий что сессия стартовала.
Несколько встроенных плагинов его реализуют.
FixtureManager. Создает FixtureManager
object and assigns it to the session. Как следует из название FixtureManager
ответственен здесь за сбор, управление и запуск фикстур.
Runner. Создает a SetupState
объект. SetupState
стек в виде словаря который отвечает управление порядком сетапа и теардауна
Здесь записывается состояние self.stack
для той части цепочки нодов для которой еще не записано. Далее, с множеством технических проверок, в обратном порядке вызываются финализаторы.
Хук pytest_collection(session: Session). Под капотом вызывает сессию perform
collect
. Как следует из названия возвращает коллекцию собранных айтемов(элементов/items). В некотором смысле, это ядро стадии стадии коллекции.
Примеры реализаций:
Ядро
Вдохнули?
Во время вызова session perform_collect
ноды рекурсивно добавляются к дереву вызова. В дереве вызовов - объект сессии строится как дерево с корнем в виде объекта сессии и листьями в виде тестовых вызовов (test invocations). Я буду в дальнейшем называть это деревом тестовых вызовов.
Класс |
Описание |
Node |
Все ноды наследуют этот класс. Основные свойства - |
Collector |
Представляет собой абстрактный класс для коллекций. Задает метод collect. |
FSCollector |
Нода которая ассоциирована с сущностями в файловой системе(дирректория, файл, модуль etc). Наследует |
PyCollector |
Нода которая ассоциирована с модулем или классом. Наследует |
Item |
Представляет собой тестовый вызов. Имеет |
Function |
Нода которая ассоциирована с функцией. Имеет 2 важных свойств |
DoctestItem |
Pytest может вызывать док тесты, записанные как интерактивные команды python, по умолчанию, test*-.txt файлах. Данный айтем по сути - реализация данных тестовых вызовов. Мы не будем останавливаться на данных тестовых вызовах. Наследует Item. |
Class |
Нода для класса. Наследует |
Module |
Нода для модуля. Наследует пустой класс File который наследует |
File |
Функция у ноды одна - загрузить соответствующий модуль. Наследует |
Package |
Нода для пакета. Атрибут |
Dir |
Нода для дирректории. Собирает файлы, директории и пакеты расположенные в данной директории. Наследует |
Session |
Сбора данных выполняется начиная с данной ноды, создавая session как корень дерева тестовых вызовов. Далее рекурсивно собираются все потомки для которых вызывается методы Наследует |
Пример конструктора Session:
На первом этапе сбора вызывается perform_collect
метод объекта Session.
Сначала вызывается resolve_collection_argument
- добавляет пути из командной строки к дефолтным путям сессии. Все. На данном этапе данные пути “замораживаются” (frozenset).
Далее вызывается метод :
После функия сollect_one_node
(Collector) расположенная в _pytest/runner.py и рекурсивно собирает дерево тестовых вызовов.
Под капотом вызывается 2 хука pytest_collectstart(Collector)
и pytest_make_collect_report(Collector)
.
В хуке Pytest_collectstart
проверяется следует ли остановить сессию или нет. Хук pytest_make_collect_report рекурсивно выполняет основную работу по сбору дерева тестовых вызовов.
Плагин |
Релизация |
runner |
Помимо некоторых других настроек, вызывает collect() возвращающий CollectReport. Мне показалось здесь интересным решение с CallInfo оберткой над функцией, которая инкапсулирует и возвращает Result/Exception информацию о вызове функции. |
Этот метод collect() в runner
- реализации абстрактного метод класса Collect. В сессии реализуется так:
В общих чертах/мазках можно изобразить так:
По сути в цикле рекурсивно собираются потомки - сабноды текущей ноды. И для каждой сабноды вызывается соответствующий хук - pytest_collect_directory, pytest_collect_file, pytest_pycollect_makemodule. Соответственно собираются директории, файлы и модули. В pytest_collect_file модуль собирается базируясь на расширении файла (собираются файлы с расширением .py) и путей в Session.
Когда дело доходит до pytest_pycollect_makemodule - который следуя из названия собираются ноды для модуля, то под капотом FixtureManager помощью parsefactory собирает фикстуры основываясь на информации о модуле.
Интересный момент, который мне здесь показался, создание обертки FixtureFunctionDefinition над FixtureFunction. Сделано это для того чтобы избегать прямых вызовов функции. По сути сначала вызывается декоратор fixture. Который возвращает FixtureFunctionMarker. Который при вызове возвращает FixtureFunctionDefinition. Вот такая иголка в яйце, яйцо в утке)
Далее вызывается метод collect Pycollector.
Предположим у нас такая организованная по директориям структура:
tests/
package1/
init.py
test_module_
a.py
::TestClass1
::test_method1
::test_func_1
package2/
init.py
test_module_
b.py
package3/
init.py
folder4/
test_module_
c.py
test_module_
d.py
test_module_
e.py
::TestClass2
::test_method2
::test_func2
Пользователь запускает pytest:
pytest tests/package1 tests/package3/folder4 tests/package3/test_module_d.py
Тогда функция вернет 2 ноды Package для package1 и package2, and 2 Ноды Module nodes для test_module_c
and test_module_
d соответственно. Заметьте что folder4 не пакет т.к. У нее нет init.py, поэтому эта директория собирается как Module. Пользователь может специфицировать любую ноду любой иерархии- пакет, модуль, класс, функции или даже тестовый вызов.
Далее для каждой найденного айтемы функции вызывается pytest_pycollect_makeitem(collector: Module | Class, name: str, obj: object) который генерирует тестовые вызовы obj - это объект айтемов(Функия или класс):
Это firstresult хук.
Реализовано с помощью:
Python Если obj
это тестовый класс, a Class
объект будет создан и возвращен.
Интересная функция здесь safe_isclass
- она нужна для того чтобы если выброситься любое исключение преобразования объекта в класс вернуть False.
Если obj это функция, сначала a объект класса MetaFunc который ответствен за параметризацию создается затем список тестовых вызовов будет создан используя pytest_generate_tests хук и возвращен как результат выполнения хука.
Соотвенно далее вызывается Для каждого объекта
MetaFunc
вызывается pytest_generate_tests(metafunc: MetaFunc) хук
Реализации:
fixtureManager,Для каждой фикстуры которая вызывается в metafunc
использует, вызывается metafunc.parametrize
которая создает и заполняет metafunc._calls.
Помимо этого создается транзитивное замыкание FuncFixtureInfo
для фикстур айтемов, фикстур полученных во время parsefactory
(register).
Также возможно сокращение количества используемых фикстур для функций с помощью prune_dependency_tree
, который вырезает фикстуры с одноименными прямыми параметрами.
Python. Реализует генерацию тестовых вызовов основываясь на маркерах прямых @pytest.mark.parametrize тестовых вызовов для каждого вызова metafunc.parametrize.
После того все айтем собраны, последними вызовом вызывается pytest_collection_modifyitems и pytest_collection_finish хуки соответственно.
Предпоследним вызывается pytest_collection_modifyitems(session: Session, config: Config, items: list[Item])
Реализован с помощью nfplugin(hookwrapper, tryfirst), lfplugin(hookwrapper, tryfirst), stepwiseplugin, funcmanage, main, mark
Плагин |
Реализация |
Cache nf plugin |
Если включен, сортирует плагины, оставляя только те которые новые со времен последнего запуска. Управляется соответственно опцией. |
Cache lfplugin |
Если включен, сортирует плагины, оставляя только те которые упали ранее. Включается соответствующей опцией. |
Cache stepwiseplugin |
Если включен, сохраняет и останавливается только на первом падении, с которого начинает дальнейшее прохождение |
funcmanage |
Делает важную работу. Он сортирует фикстуры для удобства и консистености вызова вызова. |
main |
Удаляет все плагины зарегистрированные с помощью deselected опции. |
mark |
Пользователь может указывать какие тесты запускать/игнорить с помощью опций тесты -k, -m, соответственно, игнорирует эти данные. |
Можно выдыхать)
В качестве финальной стадии рассмотрим в общих чертах цепочку в которой крюки/хуки запускаются.
После создания конфига запускается pytest_cmdline_main
хук.
Тогда запускаются pytest_sessionstart
, pytest_collection
, pytest_runtestloop
и pytest_sessionfinish
хуки. Мы рассмотрели первую часть - этап сбора коллекции pytest.
Вроде бы полотно статьи вышито, пожалуйста оставляйте отзывы.
У pytest достаточно много качественного когда и фичей которые можно еще реализовать, пожалуйста присоединяйтесь) к opensourse проектам.
Что мне показалось интересным в коде pytest
Что мне показалось в коде интересным:
То как pytest сортирует фикстуры в следующих частях продолжения рассмотрю как это работает.
Интересный момент здесь - использование метаклассов для паттерна фабрики.
Интересный момент - как pytest кэширует здесь определения функция для плагинов как мэпу для того чтобы не искать
Для меня непонятно почему CallInfo вызывается в некоторых участках кода и не вызывается в других.
Много почерпнула для себя паттернов программирования - например использование паттерна from_parrent чтобы избежать перегрузки конструктора
Открытые для меня вопросы
Нужна ли проверка на то что собранная фиксутар является фикстурой на этапе хука pytest_pycollect_makemodule после того как фикстуры подготовлены. Я задала вопрос на форуме но пока не ясна необходимость проверки. Как я понимаю некоторые unittest magic mock могут мимикрировать фикстуры.
Заключение
Мы прошлись по стадии коллекции, включая стадию конфигурирования. В следующей части статьи я расскажу про этап сбора коллекций и немного углубимся как хуки работают.
Если у Вас есть вопросы или любые, даже самые глупые мысли по данному проекту, пожалуйста, пишите.
Ссылки
Общая диаграмма как pytest работает(в общих мазках).
Статья в формате word.
Ссылка на pytest репозиторий.
Садра Баркбин. How does pytest work?