Привет. Меня зовут Ирина и хочу рассказать про так 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 способами:

  1. Простой способ. С помощью pluginmanager.hook.HOOK_NAME(**kwargs). Хук вызывается как с кварками обычным образом. Возвращает результат вызова хука. Пример - pytest_make_collect_report,  хук вызываемый на стадии коллекции для набора айтемов(items) после коллектирования коллекторов. Если вы не знаете что такое стадия коллекции дальше будет объяснение.

  2. Исторический. С помощью pluginmanager.hook.HOOK_NAME.call_historic (result_callback, **kwargs). Вызывается с kwargs для каждой имплементации для уже зарегистрированного плагина но и для всех будущих регистраций плагинов. Т.е. вновь зарегистрированные плагины будут проходить этот хук.

    Метод result_callback вызывается для каждого результата. Историческим плагинам доступна вся цепочка вызовов от предыдущих вызов хука. 

    Интересно что вызов исторического хука не возвращают результата. Применяется для консистентности порядка вызовов и увеличения производительности. Пример исторического хука - pytest_plugin_registered хук вызываемый при регистрации очередного плагина.

  3. Экстра вызов. С помощью 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 этапа:

  1. Стадия коллекции (collection stage). Как следует их названия, на этом этапе происходит в основном создание конфигов, регистрации плагинов и  создание самих тестовых вызовов.

  2. Стадия выполнения тестов. (call stage). На этом этапе вызываются фикстуры и  выполняются тестовые вызовы, созданные на предыдущих этапах.

  3. Стадия отчета о выполненных тестах (report stage).

В этой статье мы рассмотрим стадию коллекции. На этой стадии:

  1. Pytest перезаписывает pluginManager собственным плагин менеджером.

  2. Создается конфиг. Этот конфиг нужен для того чтобы того чтобы сохранять конфиги. 

  3. Регистрируются плагины.

  4. Создается объект сессии. Сессия нужна как корень собираемых нодов. Выполняет некие начальные настройки с путями в pytest.

  5. Рекурсивно собираются модули и функции.

  6. Собираются описания фикстур как транзитивные замыкания для функций.

  7. Создаются параметризованные вызовы функций.

Конфиги

В качестве приготовления сначала PluginManager регистрирует себя как плагин. 

Далее создается Config. При инициализации конфига вызывается хук pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager), с помощью которого добавляются опции командной строки плагинов.

Config регистрирует себя как плагин и в цикле пробегается по внутренним плагинам для их регистрации. 

Сам config._parse это инстанс Parser (определенный in src/_pytest/config/argparsing.py) который под капотом использует python's argparse.ArgumentParser для парсинга опций.

Плагин

Реализация

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 продолжает предыдущие тесты строчки кода где есть упавшие тесты.

Сбор начальных данных

Несколько встроенных плагинов его реализуют.

FixtureManager. Создает FixtureManager object and assigns it to the session. Как следует из название FixtureManager ответственен здесь за сбор, управление и запуск фикстур. 

Runner. Создает a SetupState объект. SetupState стек в виде словаря который отвечает управление порядком сетапа и теардауна

Здесь записывается состояние self.stack для той части цепочки нодов для которой еще не записано. Далее, с множеством технических проверок, в обратном порядке вызываются финализаторы.

Код вызова сетап для ноды
Код вызова сетап для ноды
Вызов финализаторов
Вызов финализаторов
  • Хук pytest_collection(session: Session). Под капотом вызывает сессию perform collect. Как следует из названия возвращает коллекцию собранных айтемов(элементов/items). В некотором смысле, это ядро стадии стадии коллекции.

Примеры реализаций:

Плагин

Реализация

main

Вызывет perform_collect метод обьекта session. После items member of the session. 

assertion

Заменяет некоторые ассерты АСТ дерева в целях лучшей выразительности. Имплементирует  PEP 302. Неплохая статья здесь (на английском).

Ядро

Вдохнули?

Во время вызова session perform_collect ноды рекурсивно добавляются к дереву вызова. В дереве вызовов - объект сессии строится как дерево с корнем в виде объекта сессии и листьями в виде тестовых вызовов (test invocations). Я буду в дальнейшем называть это деревом тестовых вызовов

Класс

Описание

Node

Все ноды наследуют этот класс. Основные свойства - nodeId, parent, и path. Содержит линку на session объект.

Collector

Представляет собой абстрактный класс для коллекций.  Задает метод collect.

FSCollector

Нода которая ассоциирована с сущностями в файловой системе(дирректория, файл, модуль etc). Наследует Collector.

PyCollector

Нода которая ассоциирована с модулем или классом. Наследует Collector. Сохраняет ассоциированный объект как obj. Собирает и возвращает своих потомков в методе collect.

Item

Представляет собой тестовый вызов. Имеет runtest метод который реализуется потомками. Наследуется от  Node.

Function

Нода которая ассоциирована с функцией. Имеет 2 важных свойств fixtureinfo and callspec. fixtureinfo:FuncFixtureInfo информацию о фикстурах. Использует callspec:CallSpec2 какие параметре и/или фикстуры вызываются в случае параметризации. Наследует Item.

DoctestItem

Pytest  может вызывать док тесты, записанные как интерактивные команды python, по умолчанию,  test*-.txt файлах. Данный айтем по сути - реализация данных тестовых вызовов. Мы не будем останавливаться на данных тестовых вызовах. Наследует Item.

Class

Нода для класса. Наследует PyCollector

Module

Нода для модуля. Наследует пустой класс File который наследует FSCollector.  

File

Функция у ноды одна - загрузить соответствующий модуль. Наследует FSCollector.

Package

Нода для пакета. Атрибут obj это the init расположенного в пакете.  Наследует  Module.

Dir

Нода для дирректории. Собирает файлы, директории и пакеты расположенные в данной директории. Наследует  FSCollector.

Session

Сбора данных выполняется начиная с данной ноды, создавая session как корень дерева тестовых вызовов. Далее рекурсивно собираются все потомки для которых вызывается методы collect

Наследует Collector. Хранит пути и соответствующие айтемы.

Пример конструктора 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

    conftest.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.

Пример кода использования  is_safeclass
Пример кода использования is_safeclass

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

Пример кода генерации тестовых вызовов
Пример кода генерации тестовых вызовов

Реализации:

fixtureManager,Для каждой фикстуры которая вызывается в metafunc использует, вызывается metafunc.parametrize которая создает и заполняет  metafunc._calls.

Помимо этого создается транзитивное  замыкание FuncFixtureInfo  для фикстур айтемов, фикстур полученных во время parsefactory(register).

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

Python. Реализует генерацию тестовых вызовов основываясь на маркерах прямых  @pytest.mark.parametrize тестовых вызовов для каждого вызова  metafunc.parametrize.

После того все айтем собраны, последними вызовом вызывается pytest_collection_modifyitems и pytest_collection_finish хуки соответственно.

Реализован с помощью 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 чтобы избежать перегрузки конструктора

Открытые для меня вопросы

Заключение

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

Если у Вас есть вопросы или любые, даже самые глупые мысли по данному проекту, пожалуйста, пишите.

Ссылки

Комментарии (0)