Как адаптировать терминальные отчёты pytest под свои интересы

от автора

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

Зачем менять строчки вывода в отчётах pytest?

Ниже в статье — почти исключительно ответы на вопросы вида «Как что-то сделать?».

А в этом подразделе — постараюсь коротко — про то, зачем.

  1. Стандартный отчёт pytest не рассказывает, что, собственно тестируется. Косвенно эти данные можно зашить в названия тест-функций, но — а как быть с фикстурами и переменными? А с метками? Да много с чем.

  2. Если практиковать Test Driven Design (конечно же, его учебную, а не промышленную версию), то создание тест-кейсов сводится к тестированию требований к модулям. Обычно даже не всех требований, а только умеренно публичных: интерфейсы классов и т. п. И хотелось бы эти требования в отчёте видеть явным образом.

  3. Меня хлебом не корми — дай адаптировать очередной интерфейс. Ну хоть капельку! Но это личное.

  4. Русский язык — хорошая, годная вещь. Его использование в отчётах лично для меня очень ускоряет достижение результата. Особенно (повторюсь) если задача учебная и вряд ли обрастёт пользователями.

  5. Чтобы хоть что-то понять в программировании, нужно читать чужой код. Чем pytest плох? Ничем. Хороший. Почитаю его. Как раз вопросов к мирозданию у меня накопилось очень много. Грузить вас не буду, но если коротко, то с ООП я дружбу не вожу пока совсем. А пора.

Наверняка у вас есть и другие причины. Так или иначе, бывает нужно. Всё, дальше — только про «как».

Куда лезем

Коротко напомню, что делает pytest.

  1. Находит в Python-файлах каталога все функции, которые согласен считать тестовыми.

  2. Запускает эти функции.

  3. По каждой функции выдаёт отчёт: отработала или сломалась.

Как минимум изобилие префиксов test_ точно портит картинку

Как минимум изобилие префиксов test_ точно портит картинку

Отчёт-то нас и интересует: откуда он берётся? Это какие-то буквы — как их можно поменять? А раскрасить можно? А по-русски написать? А что ещё?

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

  1. Каждый раз при запуске pytest проходит по одному и тому же пути. Сначала один этап, потом другой, потом третий. Небольшие вариации зависят от ключей запуска и прочих обстоятельств, но основная схема прибита гвоздями.

  2. Каждый этап тоже структурирован достаточно жёстко и очень линейно. Сначала одно, потом другое. Иногда — исключения и их обработка. Иногда пропуск каких-то шагов. Но схема очень стабильна.

  3. Каждый переход от одного большого шага или маленького шажочка к следующему содержит так называемый hook — крючок. Прямо в коде самого pytest есть места — дальше называем их хуками — в которые вы можете вписать свою логику. И таким образом вмешаться в поведение системы. Например, что-то поменять в данных. А ведь строка отчёта — это тоже данные!

  4. Данные, которыми оперирует pytest по ходу работы, живут своей объектно-ориентированной жизнью. Классами и объектами. Это обычные классы и объекты Python — и когда вы с помощью хуков влезаете со своей логикой в единую логику pytest, то получаете и доступ к его данным. То есть на самом деле — к информации о тестах. В том числе к информации об их названиях, докстрингах и прочих нужных в хозяйстве мелочах.

Итого у нас есть какая-то — пусть пока нам и неизвестная — схема работы pytest. Мы умеем в неё вмешиваться и что-то менять. А значит, влиять на результат работы. Например, на то, как выглядит отчёт.

Напрашивается план.

  1. Разобраться в схеме работы pytest: этапах и их структуре.

  2. Понять, где в этой схеме находятся точки вмешательства — хуки.

  3. Понять, как в эти точки вставлять свою логику.

  4. Понять, как обращаться к данным pytest — и каким данным.

  5. Придумать, какие данные и в каких точках нам нужно менять, чтобы в итоге получить отчёт в милом сердцу и уму формате.

Будем его реализовывать. Но в той последовательности, которую продиктовала реальность. Логике пришлось подвинуться.

Узнаем, как вмешиваться («писать хуки»)

Без этого знания дальше никуда. Потому что вся документация описывает почти исключительно то, как пользоваться хуками.

Всё оказалось очень просто. Чтобы вмешаться в конкретную точку всего большого процесса работы pytest, нужно написать функцию с именем, присущем этой точке. И положить эту функцию в файл conftest.py. А файл — в корневой директорий вашего каталога тестов.

То есть нам предстоит просто писать какие-то функции. И складывать их в один файл.

Эти функции будут делать единственное, что в таких случаях могут функции — менять объекты, ссылка на которые к ним пришла в аргументах. Обычная Python-деятельность. Ну и хорошо.

Подробней про conftest.py и вырастающие из него плагины можно прочитать очень много где. Список полезных текстов — в конце статьи. Источник всех полезных знаний — как всегда, документация: в ней есть статья про то, как писать плагины.

Нарисуем схему

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

Тут всё просто: берём Figma, заливаем в неё сведения из официальной документации и красиво раскладываем по полотну. Получается двумерно. Так закономерности видней, а масштаб работ — охватней.

Можно скачать и PDF, и Figma-исходник

Можно скачать и PDF, и Figma-исходник

Почему, кстати, Figma? Потому что на самом деле неважно, какой инструмент вам привычен, чтобы разбираться в большом объёме информации и структурировать её. Годится даже мозг. Но мне проще сначала разложить буквы по экрану. Figma же я просто знаю — и знаю, как её для этого дела применить. Так-то хоть MS Visio, хоть Mermaid, хоть Google Sheets — что угодно годится, даже то, чего у нас нет.

Главное, чтобы инструмент был предельно гибок в выявлении таксономий. Figma, как видите, справляется.

Разумеется, вы можете утащить эту схему себе (Figma-файл) и поменять её под свои нужды. Ну или просто скачать-поизучать (PDF). Уточнениям буду только рада.

Пометим полезные точки вмешательства

Одна из мощных концепций Figma — компоненты — позволяет ставить аккуратные иконки рядом с контент-карточкой лёгким движением руки. И убирать. И менять. И ничто никуда не ползёт. Хорошая Figma, хорошая, да. Но вернёмся к программированию.

Смотреть на короткие карточки по мотивам документации во время анализа кода оказалось очень удобно

Смотреть на короткие карточки по мотивам документации во время анализа кода оказалось очень удобно

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

Остаётся совсем немного интересных названий. Все они помечены на схеме одной из двух иконок — либо «Используется», либо «Может пригодиться». Пора зарываться в детали.

Пощупаем данные

Тут полезно со скрипом вспомнить то, что почему-то «забывает» рассказать как незначащую мелочь половина учебных пособий про ООП, а именно: объекты — это инкапсуляция в первую очередь поведения. Данные тут на полшага позади. Идут прицепом. А где идут? А вот как раз в аргументах этого самого поведения — то есть хук-функций.

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

Тут я немного поленилась и не стала ничего визуализировать. Суперполезный класс нашёлся, собственно, только один — Item. Его точно стоит изучить под микроскопом.

Например, много пищи для размышлений даст запуск pytest с хотя бы одним непустым тестовым файлом и таким conftest.py:

# conftest.py import pytest from pprint import pprint  def pytest_itemcollected(item):     """ Срабатывает сразу после того, как найден очередной тест-кейс (item).         Посмотрим на все атрибуты этого объекта.     """     print("\n\n")     pprint(item.__dict__)     print("\n\n")

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

{'_fixtureinfo': FuncFixtureInfo(argnames=(),                                  initialnames=(),                                  names_closure=[],                                  name2fixturedefs={}),  '_instance': None,  '_obj': <function test_pass at 0x10a3b7ba0>,  '_report_sections': [],  '_request': <FixtureRequest for <Function test_pass>>,  'extra_keyword_matches': set(),  'fixturenames': [],  'funcargs': {},  'keywords': <NodeKeywords for node <Function test_pass>>,  'originalname': 'test_pass',  'own_markers': [],  'stash': <_pytest.stash.Stash object at 0x10a37b280>,  'user_properties': []}

Если недосуг печатать — можно скачать папку с этим кодом из Github-репозитория проекта.

Потрясающая воображение концепция, которую вроде как знают все спецы — но непонятно, откуда взять из воздуха новичкам-самоучкам — так вот, концепция выглядит вот так:

# находимся в хуке pytest_itemcollected() item.oneliner = OneLinerItem(item) ... # находимся в хуке pytest_collectionfinish() for item in items:     item._nodeid = item.onliner.doc

То есть мы можем «прицепить» нужные данные к нашему тест-кейсу, буквально на месте создав ему новый атрибут. В атрибут положить объект класса, инкапсулирующего любые данные на наш вкус. А потом обратиться к этим данным в совершенно другой точке программы (например, ближе к выводу результатов). И никто нам ничего не сделает за такие вольности. Ну и дела.

Найдём, где в pytest лежит (или появляется) текст строки отчёта

Кажется, с этого надо было начинать. Но без понимания того, как устроен pytest, это не принесло бы особой пользы. А теперь — принесёт.

Disclaimer. Вообще-то в одной из статей списка референсов (см. ниже) дан готовый ответ. И даже работоспособный код. Самостоятельно можно ничего не искать и ничего не писать. Но — не последний же раз в жизни приходится заниматься reverse engineering’ом. Лучше заранее набить руку и понять, как к этому ответу прийти.

Давайте заметим, что в строке отчёта — или где-то непосредственно перед ней — обязательно должен использоваться хук pytest_report_teststatus(). Он отдаёт метки результата выполнения тест-кейса: PASSED и тому подобное. Причём для каждого тест-кейса создаёт эти метки отдельно (да-да, можно делать индивидуальные метки для каждого тест-кейса, если вам это вдруг нужно). То есть стоит попытаться зацепиться за вызовы этого хука, а потом за вызовы вызовов — и так по цепочке дойти до места, где формируется и/или выводится строка отчёта.

Забираем себе код pytest с Github (Mac или Linux здесь и далее везде, про жизнь на Windows просто ничего не знаю):

git clone git@github.com:pytest-dev/pytest.git

Находим вызов хука pytest_report_teststatus() в коде pytest:

cd pytest/src/_pytest find . -name "*.py" -exec grep "report_teststatus" /dev/null {} \;

Читаем результат. Видим два места, где эта функция вызывается, а не определяется.

Четыре def и один текст для help — всё не так страшно

Четыре def и один текст для help — всё не так страшно

Ага, в файле reports.py уровнем выше вызова хука находится интересная функция _get_verbose_word_with_markup().

#reports.py def _get_verbose_word_with_markup(     self, config: Config, default_markup: Mapping[str, bool] ) -> tuple[str, Mapping[str, bool]]:     _category, _short, verbose = config.hook.pytest_report_teststatus(         report=self, config=config     )     ...

Находим, где она используется — несколько мест в файле terminal.py.

find . -name "*.py" -exec grep "get_verbose_word" /dev/null {} \;

Очевидно, становится горячо.

Слово word как бы намекает — вывод близко!

Слово word как бы намекает — вывод близко!

Обнаруживаем, что все места вызова — внутренние функции метода short_test_summary() какого-то класса. Неважно даже, и какого — но ясно, что вывод строки отчёта где-то рядом.

# terminal.py def short_test_summary(self) -> None:     ...     def show_xfailed(lines: list[str]) -> None:         xfailed = self.stats.get("xfailed", [])         for rep in xfailed:             verbose_word, verbose_markup = rep._get_verbose_word_with_markup(                 self.config, {_color_for_type["warnings"]: True}             ) ...

Читаем код в окрестностях этого метода. И чуть выше находим вот это:

# terminal.py def _locationline(         self, nodeid: str, fspath: str, lineno: int | None, domain: str     ) -> str:         def mkrel(nodeid: str) -> str:             line = self.config.cwd_relative_nodeid(nodeid)             if domain and line.endswith(domain):                 line = line[: -len(domain)]                 values = domain.split("[")                 values[0] = values[0].replace(".", "::")  # don't replace '.' in params                 line += "[".join(values)             return line          # fspath comes from testid which has a "/"-normalized path.         if fspath:             res = mkrel(nodeid)             if self.verbosity >= 2 and nodeid.split("::")[0] != fspath.replace(                 "\\", nodes.SEP             ):                 res += " <- " + bestrelpath(self.startpath, Path(fspath))         else:             res = "[location]"         return res + " "

Подозрительно знакомые пара двоеточий! И квадратные скобки. И название переменной line. Может быть, мы зря скачем по файлам, а нужно просто посмотреть на все вхождения ::? Посмотрим — в лоб поиском по terminal.py.

Ну а дальше трудно не заметить, что пара двоеточий постоянно встречается с одним и тем же атрибутом какого-то класса — атрибутом nodeid. Просто везде они рядом идут.

Не попробовать ли нам менять nodeid там, где мы их видим? А мы их видим в классе Item (точнее, в его родительском классе Node) очень чётко.

Для начала поменяем значение этого атрибута в простом понятном хуке pytest_itemcollected() — напомню, что он срабатывает сразу, как только мы нашли тест-кейс.

Попробовать:

# conftest.py def pytest_item_collected(item):     """ Срабатывает сразу после того, как найден очередной тест-кейс (item).       Поменяем nodeid и посмотрим, сказалось ли это на строке отчёта.   """     item.nodeid="blablabla"

Ой!

Ишь ты, has no setter, слова-то какие грозные

Ишь ты, has no setter, слова-то какие грозные

Похоже, pytest писали люди, кое-что понимающие в ООП. Кто бы мог подумать. И конечно, они спрятали от нас возможность вот так вот в лоб присваивать значения некоторым атрибутам объектов. И даже в списке атрибутов эти некоторые не выводятся.

Ну вы знаете этот фокус — а давайте сделаем как бы скрытый (на самом деле нет) атрибут _attribute, а на вызов метода attribute прицепим декоратор @property. И вот у нас уже появляются слова «геттеры» и «сеттеры», и всё становится загадочно и недоступно для изменений.

Найдём геттер нашего nodeid в коде самого pytest. Ищем даже не в Item, а в его родительском классе Node (то есть сначала-то в Item поискали, просто не нашли).

# nodes.py class Node(metaclass=NodeMeta): ...     @property     def nodeid(self) -> str:         """A ::-separated string denoting its collection tree address."""         return self._nodeid

А сеттера-то у атрибута и нет, только геттер! Ну ладно. Тогда пойдём поперёк всех правил приличия и будем в лоб работать с item._nodeid.

# conftest.py def pytest_item_collected(item):     """ Срабатывает сразу после того, как найден очередной тест-кейс (item).       Поменяем nodeid и посмотрим, сказалось ли это на строке отчёта.   """     item._nodeid="Гюльчатай, открой личико"

Тадам! Нашли:

Всё, шевелящийся прототип готов. Осталось начать и кончить.

Да, если появление тут цитаты из кода pytest кажется чудом, то обратите внимание на ссылку [source] в некоторых местах документации по API pytest. Эта ссылка ведёт прямо на нужный кусок кода. Всё время потом будет нужно.

Прямая ссылка: https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.nodes.Node

Свяжем всё в работающую систему

Когда код-то будем писать? А вот как раз пора.

Сначала проверим, что во всех наших потенциально интересных хуках мы действительно можем сделать что-то интересное.

# conftest.py import pytest  def i_am_here(function):     """ Сигнал о том, что мы внутри конкретного хука.     """     name = function.__name__     doc = function.__doc__.strip()     print(f"\n- - - {name} - - -\n{doc}\n")  def pytest_configure(config):     """ Срабатывает сразу после парсинга командной строки.     """     i_am_here(pytest_configure)      def pytest_addoption(parser):     """ Добавляет ключ к возможным ключам запуска pytest.     """     i_am_here(pytest_addoption)  def pytest_itemcollected(item):     """ Срабатывает сразу после того, как найден очередной тест-кейс (item).     """     i_am_here(pytest_itemcollected)  def pytest_collection_modifyitems(session, config, items):     """ Фильтрует-сортирует список найденных тест-кейсов.     """     i_am_here(pytest_collection_modifyitems)  def pytest_report_teststatus(report, config):     """ Меняет метку результата запуска тест-кейса.     """     i_am_here(pytest_report_teststatus)  def pytest_runtestloop(session):     """ Запускается основной цикл тестирования и вывода результатов.     """     i_am_here(pytest_runtestloop)

В выводе есть вся нужная нам информация и даже немного лишней.

Во-первых, метка результатов меняется трижды. Почему? А потому, что для каждого тест-кейса у нас есть три этапа обработки: setup, call и teardown. И — согласно документации — они отличаются только значением аргумента report.when. Оставим только значение “call”.

# conftest.py def pytest_report_teststatus(report, config):     if report.when == "call":     i_am_here(pytest_report_teststatus)

Чтобы такое «потому» понимать сразу, нужно познакомиться с ключевым процессом обработки тест-кейса. Вот он, описан в документации к хуку pytest_runtest_protocol() и отдельно показан в зелёной выноске на схеме.

Выглядит странно, но в реальности бешено помогает — даже удивительно

Выглядит странно, но в реальности бешено помогает — даже удивительно

Во-вторых, мы же смотрим только на запуск pytest с ключом -v. А как насчёт других ключей? Из самых остроактуальных — -q (компактный отчёт, точнее, отмена ключа -v) и --co (только сбор тест-кейсов, но не их вывод).

Вот, например, как выглядит вывод с --co:

Очевидно, для вывода списка найденных тест-кейсов в приятном глазу формате мы можем использовать только два хука: pytest_collection_modifyitems() и pytest_runtestloop(). Остальные — сверьтесь со схемой — если я не ошибаюсь, доступа к списку найденных тест-кейсов (items) не имеют. Или хорошо спрятали.

Что ж, научимся выводить что-то (потом придумаем, что именно) только тогда, когда pytest запущен с ключом --co:

# conftest.py def pytest_runtestloop(session):     """ Запускается основной цикл тестирования и вывода результатов.     """     i_am_here(pytest_runtestloop)     if session.config.getoption("collectonly"):         print(f"В режиме --collect-only найдено тест-кейсов: {len(session.items)}")

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

На схеме выше эта картинка уже есть. Такая уж магия перемещения во времени.

К сожалению, на картинке есть сильное упрощение. Суть в том, что в компактном отчёте тесты предварительно группируются по _nodeid — и происходит это позже, чем вызывается хук pytest_itemcollected(). Об этом кое-что написано ниже.

Ну а теперь уже начинаем заменять заглушки реальными данными, имеющими отношение к конкретным тест-кейсам.

# conftest.py import pytest from dataclasses import dataclass  """ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *      Hooks («хуки») для перенастройки строк отчёта о тест-кейсах.     * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * """   """ Хуки инициации. Собирают данные и настраивают pytest. """  def pytest_configure(config):     """ Срабатывает сразу после парсинга командной строки.         Нужно получить конфигурацию как можно быстрее, поэтому здесь.     """     OneLineState(config)  def pytest_addoption(parser):     """ Добавляет ключ к возможным ключам запуска pytest.         Ключ используется как булево значение.     """     op = OneLinePreset()     group = parser.getgroup(op.group, op.description)     group.addoption(str(op.dashkey),                     action="store_true",                     help=op.helpp)  def pytest_itemcollected(item):     """ Срабатывает сразу после того, как найден очередной тест-кейс (item).         Здесь используем, чтобы сразу подготовить данные для сбора строки.     """     item.oneline = OneLineItem(item)   """ Хуки формирования строки. Реализуют логику символов и надписей. """   def pytest_collection_modifyitems(session, config, items):     """ Меняет состав и параметры найденных тест-кейсов.         Запускается, как только все тесты найдены.     """     if OneLineState().on:         for item in items:             item._nodeid = item.oneline.format()  def pytest_report_teststatus(report, config):     """ Меняет метку результата запуска тест-кейса.     """     # TODO Обрабатывать ситуацию SKIPPED     if report.when == 'call' and OneLineState().on:         match report.outcome:             case 'passed':  signs = ['+', 'Всё хорошо']             case 'skipped': signs = ['/', 'Так и надо']             case 'failed':  signs = ['!', 'Опаньки']             case _:         signs = ['', '']         return (report.outcome, *signs)  def pytest_runtestloop(session):     """ Запускается основной цикл тестирования и вывода результатов.         Для режима --collect-only срабатывает только сам хук.      """     if session.config.option.collectonly:         print("\n".join([i._nodeid for i in session.items]))

Тааак, а что это за OneLine* такой? И дальше тоже всюду в коде рецептов встречаются классы с префиксом OneLine, атрибуты oneline и их производные.

Это не какие-то специальные объекты pytest. Просто у меня так названы нужные переменные. А также организованы в класс и проассоциированы с другими классами через атрибут. В общем, пишите там что хотите, можно даже сразу значения.

Лежит это богатство прямо в conftest.py, чтобы не усложнять конструкцию.

Мой собранный на скорую руку OneLine* прямо сейчас на конкретном проекте:

# conftest.py @dataclass class OneLinePreset:     """ Настройки системы. Датакласс.     """     key: str = "Ы"     dash: str = "-"     helpp: str = "Вывод строк отчёта в своём формате"      group: str = "Ключи плагина OneLine"     description: str = "Потихоньку добавляем то, что нужно."      file_prefix: str = "test_"  # TODO Расширить до использования .python_files     template: str = "{o.module} | {o.cls}{o.function:5} • {o.doc:25} {o.params}"      @property     def dashkey(self):         return self.dash + self.key   class OneLineState:     """ Состояние системы. Контейнер для config. Singleton.     """     instance = None      def __new__(cls, config=None):         if cls.instance is None:             instance = super().__new__(cls)             cls.instance = instance         return cls.instance      def __init__(self, config=None):         if config is not None:             self.config = config             self.preset = OneLinePreset()             self.on = bool(config.getoption(self.preset.key))         else:             if self.config is None:                 raise OneLineException("Нет данных конфигурации, а нужны.")      @classmethod     def __getattr__(cls, name):         try:             return getattr(cls, "_" + name)         except Exception:             raise OneLineException(f"Атрибут {name} в классе OneLineState не существует.")   class OneLineItem:     """ Методы и функции для работы со строкой отчёта.     """     def __init__(self, item):         self.item = item  # Родительская строка         self._init_doc()         self._init_module() # .module — имя тестируемого модуля без префикса "test_" и окончания ".py"         self._init_class()  # .cls — тест-класс тестирующей функции         self._init_function()  # .function — название тестирующей функции без префикса "test_"         self._init_params()      def _init_doc(self):         self.doc = getattr(self.item._obj, "__doc__", "")      def _init_module(self):         node = self.item.nodeid  # В этой точке nodeid ещё не менялся         file = node.split(':')[0]         file_name = file.split('.')[0]         associated_module_name = file_name.replace(OneLinePreset().file_prefix, '')         self.module = associated_module_name      def _init_class(self):         if self.item._instance:             full_class_name = self.item._instance.__class__.__name__             associated_class_name = full_class_name.replace('Test_', '')             # TODO Совсем нехорошо зашивать сюда 'Test_', лучше использовать .python_classes             self.cls = associated_class_name         else:             self.cls = None      def _init_function(self):         self.function = self.item.name.replace(OneLinePreset().file_prefix, '').split('[')[0]      def _init_params(self):         try:             self.params = self.item.callspec.params         except Exception:             self.params = None      def format(self): """ Вот оно, форматирование строки. """         self._preformat()         return OneLinePreset().template.format(o=self)       def _preformat(self):         self._preformat_doc()         self._preformat_params()         self._preformat_cls()      def _preformat_cls(self):         if self.cls is not None:             self.cls = self.cls + ' → '         else:             self.cls = ''      def _preformat_doc(self):         if (len(self.doc) > 25): self.doc = self.doc[:23] + "..."      def _preformat_params(self):         if self.params is not None:             self.params = '(' + ', '.join([str(a) + '=' + str(b) for a, b in self.params.items()]) + ')'         else:             self.params = ''  class OneLineException(Exception):     pass

Это не код мечты. Но тут важно другое: структуру плагина можно отделить от содержания. И дальше в этом содержании творить уже любой милый сердцу бардак. Структура выдержит.

Вот результат обычного запуска pytest:

А вот — с ключом :

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

Всё! Хотя статья ещё и не кончилась

Как видите, код небольшой. Крохотный даже. И ваш будет небольшим, когда напишете. Именно поэтому тут, как мне кажется, надо не фреймворк искать, а самостоятельно сделать небольшой анализ архитектуры pytest и тут же на коленке использовать полученные знания. Заодно и руку в таких делах набить.

Ниже — куски кода, которые могут вам пригодиться, если захочется чего-то затейливого. Это далеко не все возможности кастомизации строчек отчёта. Предлагайте и свои, репозиторий на Github’е открыт для merge request’ов.

Готовые и неготовые рецепты

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

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

Правильные хуки в правильный момент

Когда и куда записывать готовую строчку отчёта для тест-кейса ⭐

Хук pytest_itemcollected() срабатывает сразу после того, как система нашла и упаковала — в объект item— очередной тест-кейс. Дальше именно этот item пойдёт на тестирование и генерацию отчёта. Поэтому лучше сразу как минимум «прицепить» к этому объекту (отдельным атрибутом .oneline или любым другим на ваш вкус) всю необходимую информацию:

# conftest.py def pytest_itemcollected(item):     """ Срабатывает сразу после того, как найден очередной тест-кейс (item).         Здесь используем, чтобы сразу подготовить данные для сбора строки.     """     item.oneline = OneLine(item)     item._nodeid = item.oneline.format()

Бывают ситуации, когда лучше само присвоение строки отчёта сделать попозже. Например, если содержание строчки отчёта конкретного тест-кейса зависит от его соседей. Тогда присвоение готовой строчки удобно делать в хуке pytest_collection_modifyitems().

# conftest.py def pytest_collection_modifyitems(session, config, items):     """ Меняет состав и параметры найденных тест-кейсов.         Запускается, как только все тесты найдены.     """     if OneLine.on:         for item in items:             item._nodeid = item.oneline.format()

В любом случае значение строчки нужно присваивать атрибуту item._nodeid.

Как обращаться к данным конфигурации системы ⭐

Время от времени вам захочется иметь под рукой объект config. Проще всего его получить в хуке pytest_configure() и сохранить в объекте, доступном из любой точки программы (например, с помощью вариации почти неприличного паттерна Singleton).

# conftest.py def pytest_configure(config):     """ Срабатывает сразу после парсинга командной строки.         Нужно получить конфигурацию как можно быстрее, поэтому здесь.     """     OneLine.config(config)          ...      class OneLine:     ....     @classmethod     def config(cls, config=None):         """ Вызываем в первом же хуке, получившем config.             Дальше используем как Singleton через свойство .on.         """         if config is not None:             cls._config = config             cls._on = config.getoption(cls.key)         else:             raise Exception("Нет данных конфигурации, а нужны.")                  @classmethod     @property     def on(self):         if self._on is not None:             return self._on         else:             raise OneLineException("Не установлен статус OneLine при запуске.")

Скорее всего, это можно написать более внятно. Буду благодарна за подсказку.

Когда сортировать и фильтровать тест-кейсы
# conftest.py def pytest_runtestloop(session):     """ Запускается основной цикл тестирования и вывода результатов.         Для режима --collect-only срабатывает только сам хук.     """     if session.config.option.collectonly:         print("\n".join([i._nodeid for i in session.items]))

Задача со звёздочкой — разобраться, как выключить мусорный вывод в этом хуке (увидите его, когда запустите).

Зачем объединять в группы ключи командной строки

Вот кусочек вывода pytest -h:

Как видите, группа — это по крайней мере группа для help’а. Удобней группировать ваши личные ключи в одну группу.

При чём тут кастомизация строк? А просто ключам только дай, и одним не ограничится. Так что лучше сразу создать для них отдельную группу.

Как изменить заголовок и финальную часть отчёта

Это отдельная тема. Придётся немного поисследовать код pytest — по той же примерно схеме, что описана выше для строк отчёта. Но начать нужно, безусловно, с использования хуков pytest_report_header() и pytest_terminal_summary().

Как отслеживать фазу сбора данных о тест-кейсах

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

Как узнать, когда какой хук срабатывает
  1. Добавьте все интересующие вас хуки в файл conftest.py (обязательно в родительском каталоге набора кейсов).

  2. В вызовы этих хуков поставьте любые диагностические print’ы на ваш вкус.

  3. Запустите pytest с теми ключами командной строки, которые вам нужны.

  4. Смотрите, что и где выводится.

Как менять символы и тексты статусов тест-кейса ⭐

Именно для этого и нужен хук pytest_report_teststatus().

Он возвращает кортеж: (статус, что показывать при -q, что показывать при -v).

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

# conftest.py def pytest_report_teststatus(report, config):     """ Меняет метку результата запуска тест-кейса.     """     # TODO Обрабатывать ситуацию SKIPPED     if report.when == 'call' and OneLineState().on:         match report.outcome:             case 'passed':  signs = ['+', 'Всё хорошо']             case 'skipped': signs = ['/', 'Так и надо']             case 'failed':  signs = ['!', 'Опаньки']             case _:         signs = ['', '']         return (report.outcome, *signs)

Ситуацию с безусловным skip этот код не отрабатывает. Хотя казалось бы. Но нет. Для обработки статусов группы skip в коде pytest есть отдельный файл skipping.py — дружить и разбираться надо с ним. Пока я тут не дошла до финала, только обнаружила вот какой код:

# skipping.py @hookimpl(wrapper=True) def pytest_runtest_makereport(     item: Item, call: CallInfo[None] ) -> Generator[None, TestReport, TestReport]:     rep = yield     xfailed = item.stash.get(xfailed_key, None)     if item.config.option.runxfail:         pass  # don't interfere     elif call.excinfo and isinstance(call.excinfo.value, xfail.Exception):         assert call.excinfo.value.msg is not None         rep.wasxfail = "reason: " + call.excinfo.value.msg         rep.outcome = "skipped"     elif not rep.skipped and xfailed:         if call.excinfo:             raises = xfailed.raises             if raises is not None and not isinstance(call.excinfo.value, raises):                 rep.outcome = "failed"             else:                 rep.outcome = "skipped"                 rep.wasxfail = xfailed.reason         elif call.when == "call":             if xfailed.strict:                 rep.outcome = "failed"                 rep.longrepr = "[XPASS(strict)] " + xfailed.reason             else:                 rep.outcome = "passed"                 rep.wasxfail = xfailed.reason     return rep

И написано в нём примерно вот что: тексты для вывода skip-статусов система получает в самом конце подготовки отчёта, причём по довольно хитрой логике. То есть написать свой хук совсем уж просто не получится — он будет переписан системой как раз в этом месте.

Можно грубо вмешаться в код вашей версии pytest и внести правки прямо в файл skipping.py. Жёстко, но работает. Альтернатива — поколдовать с декоратором @hookimpl. Тут я пока ничего не посоветую.

Информация для строки отчёта

Как получить docstring конкретной тест-функции ⭐

Если ваш хук получил объект item (то есть тест-кейс), то докстринг тест-функции — это item._obj.__doc__.

Как указать в строчке отчёта имена фикстур тест-функции

Используйте список (по умолчанию пустой) item.fixturenames. Схема использования точно такая же, как для docstring.

Как получить имя конкретной тест-функции

init.name даст вам полное имя функции, используемой в тест-кейсе item.

Чтобы избавиться от приставок и суффиксов, по которым система распознаёт тест-функции, используйте список шаблонов config.python_files.

Как получить имя тестируемого модуля ⭐

Немного кривовато, но в хуке pytest_itemcollected() можно сделать так:

# conftest.py def pytest_itemcollected(item):     ...     node = item.nodeid  # В этой точке nodeid ещё не менялся     filee = node.split(':')[0]     file_name = filee.split('.')[0]     associated_module_name = file_name.replace("test_", "")     item.oneliner.module = associated_module_name

Как узнать метки, приписанные к тест-функции

Всё так же, как и с именами фикстур, только список надо брать другой — item.own_marks. Обратите внимание, это именно собственные метки тест-функции. Меток родительского класса тут не будет.

Кстати, строчки с фикстурами и метками могут быть, например, такими:

Видно, что уже начинает хотеться осмысленных колонок. Интересно будет их когда-нибудь сделать.

Как получить информацию о тест-классе, в который обёрнута тест-функция

Тут код будет немного неожиданный и грязноватый:

# conftest.py def pytest_itemcollected(item):     ...     if item._instance:         full_class_name = item._instance.__class__.__name__         associated_class_name = full_class_name.replace('Test_', '')         # TODO Совсем нехорошо зашивать сюда 'Test_', лучше использовать .python_classes         item.oneline.cls = associated_class_name     else:         item.oneline.cls = None

Что ещё есть интересненького в информации о тест-кейсе ⭐

Вот что возвращает команда ppring(item.__dict__). Как видите, много потенциально полезного: маркеры, имена фикстур и другие интересные штуки. Обратите внимание на _obj — в этом атрибуте сохраняются все данные о коде вызываемой тестовой функции. Например, item._obj.__doc__.

{'_fixtureinfo': FuncFixtureInfo(argnames=(),                                  initialnames=(),                                  names_closure=[],                                  name2fixturedefs={}),  '_instance': <test_demo.Test_Demo object at 0x10cdb41a0>,  '_obj': <bound method Test_Demo.test_class_name of <test_demo.Test_Demo object at 0x10cdb41a0>>,  '_report_sections': [],  '_request': <FixtureRequest for <Function test_class_name>>,  'extra_keyword_matches': set(),  'fixturenames': [],  'funcargs': {},  'keywords': <NodeKeywords for node <Function test_class_name>>,  'originalname': 'test_class_name',  'own_markers': [],  'stash': <_pytest.stash.Stash object at 0x10cdb44f0>,  'user_properties': []}

А ещё давайте обратим внимание на хук pytest_runtest_logreport(). И его название, и описание ненавязчиво (правда ненавязчиво, это не ирония) намекают, что он имеет отношение к работе со строчкой.

Что значит «обратим внимание»? Зачем? Ведь хуки — это же для того, чтобы мы с вами могли написать что-то для подключения к pytest. А сами-то разработчики системы этими хуками не пользуются? На самом деле, конечно, пользуются. Хуки нам потому и достались, что весь базовый код и без того на них написан. Так что мы, создавая свои хуки, просто вписываемся в единую архитектуру pytest, а не используем какой-то отдельный способ интеграции. Удобно.

Итак, находим, где реализован этот хук в основном коде pytest:

cd src/_pytest find . -name "*.py" -exec grep "runtest_logreport" /dev/null {} \;

Получаем не то чтобы большой список мест. И перспективней всего выглядит terminal.py. Идём туда и находим код этого хука:

# terminal.py class TerminalReporter:     ...     def pytest_runtest_logreport(self, report: TestReport) -> None:         self._tests_ran = True         rep = report          res = TestShortLogReport(             *self.config.hook.pytest_report_teststatus(report=rep, config=self.config)         )         category, letter, word = res.category, res.letter, res.word         if not isinstance(word, tuple):             markup = None         else:             word, markup = word         self._add_stats(category, [rep])         if not letter and not word:             # Probably passed setup/teardown.             return         if markup is None:             was_xfail = hasattr(report, "wasxfail")             if rep.passed and not was_xfail:                 markup = {"green": True}             elif rep.passed and was_xfail:                 markup = {"yellow": True}             elif rep.failed:                 markup = {"red": True}             elif rep.skipped:                 markup = {"yellow": True}             else:                 markup = {}         self._progress_nodeids_reported.add(rep.nodeid)         if self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) <= 0:             self._tw.write(letter, **markup)             # When running in xdist, the logreport and logfinish of multiple             # items are interspersed, e.g. `logreport`, `logreport`,             # `logfinish`, `logfinish`. To avoid the "past edge" calculation             # from getting confused and overflowing (#7166), do the past edge             # printing here and not in logfinish, except for the 100% which             # should only be printed after all teardowns are finished.             if self._show_progress_info and not self._is_last_item:                 self._write_progress_information_if_past_edge()         else:             line = self._locationline(rep.nodeid, *rep.location)             running_xdist = hasattr(rep, "node")             if not running_xdist:                 self.write_ensure_prefix(line, word, **markup)                 if rep.skipped or hasattr(report, "wasxfail"):                     reason = _get_raw_skip_reason(rep)                     if self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) < 2:                         available_width = (                             (self._tw.fullwidth - self._tw.width_of_current_line)                             - len(" [100%]")                             - 1                         )                         formatted_reason = _format_trimmed(                             " ({})", reason, available_width                         )                     else:                         formatted_reason = f" ({reason})"                      if reason and formatted_reason is not None:                         self.wrap_write(formatted_reason)                 if self._show_progress_info:                     self._write_progress_information_filling_space()             else:                 self.ensure_newline()                 self._tw.write(f"[{rep.node.gateway.id}]")                 if self._show_progress_info:                     self._tw.write(                         self._get_progress_information_message() + " ", cyan=True                     )                 else:                     self._tw.write(" ")                 self._tw.write(word, **markup)                 self._tw.write(" " + line)                 self.currentfspath = -2         self.flush()

Экое богатство. Да ещё и заявлено как метод класса с многообещающим названием TestReporter. Давайте покопаемся у него в кишочках:

# conftest.py import pytest from pprint import pprint  def pytest_runtest_logreport(report):     """ Отвечает за формирование строки отчёта.         Посмотрим на все атрибуты этого объекта.     """     print("\n\n")     pprint(report.__dict__)     print("\n\n")

Выпадет много интересного, посмотрите сами. Даже время исполнения тест-кейса. Проблема в том, что это «интересное» — то есть атрибуты report — в этой точке менять уже поздно. И тут открывается поле для экспериментов.

Как показывать только имя тестирующего модуля (режим -q)

В режиме -q строка отчёта продолжает использовать атрибут nodeid. Но предварительно группирует кейсы по этим nodeid (строго говоря, это не совсем так, но суть сохраняется).

То есть если вы хотите, например, вывести в одну строку результаты запуска всех тест-кейсов одного класса — просто присваивайте этим кейсам одинаковый nodeid.

В ситуации, когда мы используем вышеописанные отдельные классы OneLine*, код будет выглядеть примерно так:

# conftest.py def pytest_collection_modifyitems(session, config, items):     if OneLineState().on:       for item in items:           if config.get_verbosity():           nodeid = item.oneline.cls         else:         nodeid = item.oneline.doc

Конечно, проверку get_verbosity можно вынести в OneLineState(), а для nodeid = создать развесистую функцию OneLine.format() c разными — для разных уровней детализации отчёта — F-шаблонами в OneLinePreset(). Но это уже развлечения на любителя.

И помните, что ключ -q нельзя использовать, если у вас перед этим не было ключа -v. Точнее, использовать-то можно. Только поведение системы будет очень, очень странным. В общем, -q как бы «отменяет» -v, но вот без него, сам по себе, приводит к неопределённости.

Как выводить время исполнения тест-кейса

Временем исполнения кейса владеет объект класса CallInfo. Там есть атрибут duration — он-то нам и нужен.

Со строкой отчёта nodeid объекта класса Item они встречаются в хуке pytest_runtest_makereport(). Можно написать примерно так:

#conftest.py def pytest_runtest_makereport(item, call):     if call.when == "call":     item._nodeid = item.nodeid + f" ({call.duration * 1_000 :3.2f}"

Можно, да нельзя:

Почему строки задвоились? В чём дело? Да похоже, в том, что nodeid каждого тест-кейса перед самым-самым выводом сравнивается с каким-то заранее сохранённым nodeid. И если они расходятся — выводит новую версию. То есть тут мы нарвались на то же самое поведение, которое нам так помогло при оформлении quiet-формата отчёта.

Сделаем иначе. Будем выводить время только у тех тест-кейсов, которые выполнились успешно. И — вместо сообщения об успехе.

# conftest.py def pytest_report_teststatus(report, config):     """ Меняет метку результата запуска тест-кейса.     """     # TODO Обрабатывать ситуацию SKIPPED     if report.when == 'call' and OneLineState().on:         match report.outcome:             case 'passed':  signs = ['+', f"{report.duration * 1_000 :.2f}"]             case 'skipped': signs = ['/', 'Так и надо']             case 'failed':  signs = ['!', 'Опаньки']             case _:         signs = ['', '']         return (report.outcome, *signs)

Вот теперь приемлемо:

Как раскрашивать текст в строке отчёта

Вообще говоря, в недрах pytest есть раскрашивалка. Но вырезать её из основного кода пока не получилось. Поэтому общий принцип такой: обрамляйте код, который вы выводите, управляющими последовательностями ANSI. Это просто работа со строчками, ничего особенного: добавить код в начале, код в конце — и на печать:

# буду жёлтеньким item.nodeid = "\x1b[33m" + item.nodeid + "\x1b[0m" 

Ключи командной строки

Как создать свой ключ командной строки ⭐

Чтобы pytest начал распознавать ключ командной строки, нужно создать хук pytest_addoption() и запустить в нём функции регистрации ключа:

# conftest.py def pytest_addoption(parser):     """ Добавляет ключ к возможным ключам запуска pytest.         Ключ используется как булево значение.         :parser: прямо так и пишите, pytest сам подставит нужное :)         :OneLine.group: строка, название группы ключей, видно в pytest -h         :OneLine.description: строка, пояснение к группе, видно в pytest -h         :OneLine.dashkey: ключ в формате -Ы (можно --Ы)         :OneLine.help: строка, пояснение к ключу     """     group = parser.getgroup(OneLine.group, OneLine.description)     group.addoption(str(OneLine.dashkey),                     action="store_true",                     help=OneLine.help)

Эти команды научат pytest только понимать, что вы указали ключ. Не ругаться на него при запуске. Но и только. А как система должна на него реагировать — это уже ваша задача. И эта задача решается использованием в хуках примерно такой конструкции:

if config.getoption(OneLine.key):     """ Всё, что в этом if, сработает, если вы указали ключ при запуске. """     ...

OneLine.key — это ключ без ведущих дефисов. То есть Ы.

А вот OneLine.dashkey — уже ключ с дефисами. То есть  или --Ы.

Конечно же, вместо OneLine.* вы можете использовать свои имена переменных.

Как проверять, указан ли в командной строке ключ использования вашего формат отчёта ⭐

Допустим, вы хотите включать свой формат по ключу  (да, кириллицей можно). Тогда после настройки этого ключа вы сможете узнавать, включён он или нет, вот так:

is_my_report = config.getoption("Ы")

Про то, где взять объект config, рассказано в другом рецепте.

Как узнать и не узнать текущий уровень детализации отчёта

Если на разных уровнях детализации вы по разному настраиваете строки отчёта, то эти уровни хорошо бы уметь узнать.

Вот как работает:

verbose = config.get_verbosity()

А вот как написано очень много где (и так не надо):

is_compact = bool(config.getoption("q")) is_extended = bool(config.getoption("v"))

Иногда используют другую запись (тоже не надо):

is_verbose = config.option.verbose is_quiet = config.option.quiet

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

Все эти значения могут быть, во-первых, не определены (привет, исключение!). А во-вторых, на самом деле они считают что-то по мотивам (именно что-то по мотивам!) количества упоминаний ключа в строке. И иногда сбрасываются в ноль. И иногда противоречат значениям из первой записи. В общем, в коде на скорую руку использовать можно, но если поймаете глюк — мало не покажется.

Как ещё можно заморочиться

Как всегда, feature list выплёскивается за границы разумного. Возможно, что-то из этого нужно вам. Тогда, если реализуете, черкните, пожалуйста, пару строк, как именно вы это сделали. Вдруг кому-то пригодится.

  • Использовать фазы setup и teardown для диагностики процесса настройки тест-кейса.

  • Интегрировать отчёты pytest с TDD, требованиями и документацией и не сдохнутьВсегда так делаю на учебных проектах. Расскажу отдельно, когда опыт поднакопится.

  • Пропускать одинаковые строчки отчёта (чтобы не повторять одно и то же).

  • Группировать тест-кейсы по меткам, по функциям, по фикстурам и т. д.

  • Использовать разные шаблоны для строчек отчёта в зависимости от режима запуска, сути тест-кейса и прочих интересных факторов (или хотя бы задавать шаблон отдельным ключом запуска).

  • Сохранять удобно форматированный отчёт в CSV-файлы и другие места.

  • Протаскивать в pytest докстринги из функций тестируемого модуля.

  • Отрефакторить по науке.

  • Создать и опубликовать свой фреймворк для настройки строк отчёта.

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

Надеюсь, статья кому-то в этом поможет.

Что можно скачать

Github — код плагина + демо-скрипты + PDF и исходник красивых схемок.

Ключевые статьи, книги, документация


ссылка на оригинал статьи https://habr.com/ru/articles/844728/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *