Подтесты в Python

Недавно я сделал опрометчивый твит, в котором намекнул на то, что у меня имеется глубоко продуманное мнение по одному важному вопросу. Я написал, что пакет
Please, Log in or Register to view URLs content!
достоин того, чтобы им пользовалось бы больше программистов. Я даже дошёл до того, что, говоря о подтестах (subtests),
Please, Log in or Register to view URLs content!
, что они были единственным, что мне по-настоящему нравилось в unittest до появления их поддержки в pytest. И, как на грех, Брайан Оккен предложил мне поучаствовать в подкасте
Please, Log in or Register to view URLs content!
, чтобы подробнее обсудить подтесты. Я могу лишь догадываться о том, что он это сделал, дабы преподнести мне урок, показать мне, что я не должен, накачавшись продуктами Splenda и травяным чаем, выдавать скороспелые мнения о тестировании кода.Но, тем не менее, когда Брайан взглянет на меня со своей хитрой улыбкой и скажет: «Итак, ты готов поговорить о подтестах?», я планировал ответить: «Да, я готов — сделал обширные заметки и набрал справочных материалов». А когда мы вместе будем стоять на сцене, получая Дневную премию «Эмми» за лучший подкаст о тестировании, я шепну ему: «Я раскрыл твою хитрость, и хотя я тебя обыграл, ты реально показал мне — что такое скромность», а по его щеке скатится одинокая слеза.

c1910c3bb533533e139aba81ad84750a.png

Или, что скорее всего так и есть, ему просто хотелось пригласить кого-то, с кем можно поговорить об этом конкретном аспекте Python-тестирования, а я оказался одним из тех немногих, встретившихся ему, кто высказывал по этому поводу своё мнение. В любом случае, этот пост будет играть роль моих заметок по
Please, Log in or Register to view URLs content!
, который появился в Python 3.4. Здесь же пойдёт речь о сильных и слабых сторонах подтестов, о сценариях их использования. Этот материал можно считать дополнением к подкасту
Please, Log in or Register to view URLs content!
.

Введение​

Механизм unittest.TestCase.subTest появился в Python 3.4, это был простой инструмент для параметризации тестов. Изначальную дискуссию, посвящённую ему, можно почитать в трекере проблем Python, в ветке
Please, Log in or Register to view URLs content!
. Там, в основном, речь идёт о деталях реализации, но там можно найти и интересные рассуждения. Этот механизм позволяет оформлять разделы тестов в виде отдельных тестов, действующих самостоятельно, с использованием менеджера контекста. Эталонным примером использования subTest является тестирование чего-либо в цикле:
Python:
Please, Log in or Register to view codes content!

Без менеджера контекста self.subTest этот тест немедленно, после того, как выполнится условие i=1, выдаст ошибку, будет сообщено о том, что test_loop завершился неудачно. Но при применении менеджера контекста неудачные завершения тестов в контексте subTest не приводят к выходу из теста, выполнение кода продолжается. Результат запуска этого теста показывает, что успешно пройдены испытания для 0, 2 и 4, а неудачно — испытания для 1 и 3. SubTest можно передать произвольные ключевые слова, они будут выведены как часть сообщения о неудачном прохождении теста. Например:
Python:
Please, Log in or Register to view codes content!

Почему бы не воспользоваться pytest.mark.parametrize или чем-то ещё?​

Пользователям pytest возможности параметризации, которые даёт subTest, не покажутся невероятно привлекательными, так как в их распоряжении уже имеется несколько подобных механизмов. Среди них —
Please, Log in or Register to view URLs content!
,
Please, Log in or Register to view URLs content!
и довольно-таки таинственный хук
Please, Log in or Register to view URLs content!
в conftest.py. Даже во фреймворке Google absltest (который, в основном, даёт незначительное расширение возможностей unittest) имеется
Please, Log in or Register to view URLs content!
для параметризации тестов.

Я склонен согласиться с тем, что, в целом, не пользуюсь подтестами для параметризации тестов. Я, в основном, пользуюсь ими тогда, когда мне совершенно необходимо применять только unittest. Например — при разработке для стандартной библиотеки. Иногда, правда, бывает так, что форм-фактор subTest даёт некоторые преимущества даже в параметризации. Например, если имеется некоторое количество тестов, которые нужно выполнить, обладающие «тяжёлой» функцией их подготовки к работе, которая собирает или загружает иммутабельные ресурсы, обычно используемые всеми подтестами:
Python:
Please, Log in or Register to view codes content!

Я уверен, что можно написать pytest-фикстуру, область видимости которой такова, что ресурс загружается только до сеанса запуска некоего набора параметризованных тестов, а сразу же после этого такой ресурс уничтожается. Но даже если это возможно без каких-либо существенных «танцев с бубном», сомневаюсь, что стороннему читателю кода, или тому, кто делает код-ревью, будет так же просто понять то, что собой представляет время жизни такого ресурса, чем в случае, когда используются подтесты.

Эти два подхода, кроме того, могут гармонично работать вместе. Подход, основанный на декораторах, применяют для написания эталонных «тестовых случаев», а подтесты используют для исследования вариаций на эту тему. Например, можно параметризовать значения входных данных тестовой функции и добавить подтесты для проверки множества свойств результата. Скажем, вот как можно поступить, если нужно проверить, что функции utcoffset() и tzname() объекта tzinfo правильно выдают несколько объектов datetime:
Python:
Please, Log in or Register to view codes content!

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

При работе с pytest.mark.parametrize мне пришлось перейти от использования unittest.TestCase к применению фикстуры subtests из пакета pytest-subtests. Дело в том, что то, как работает parametrize, несовместимо со стилем, используемым для написания тестовых случаев unittest. Правда, можно написать параметризующий декоратор, совместимый со стилем тестовых случаев unittest, поэтому прошу вас не считать это некоей фундаментальной несовместимостью рассматриваемых подходов.

За пределами параметризации​

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

Например, посмотрим на
Please, Log in or Register to view URLs content!
, который я написал для эталонной реализации PEP 615. Этот документ описывает создание нового объекта zoneinfo.ZoneInfo, который (чтобы немного упростить ситуацию) генерирует объекты-синглтоны. В первом приближении оказывается, что всякий раз, когда вызывают zoneinfo.ZoneInfo(key) с одним и тем же значением key, должен быть возвращён тот же объект, который раньше возвращался для того же значения key. Это применимо и к объектам ZoneInfo, созданных из потока байтов (с использованием модуля pickle). Поэтому речь идёт о тесте, который позволяет проверить, что если объект ZoneInfo преобразовали в поток байтов, а потом воссоздали объект из этого потока, в нашем распоряжении окажется тот же самый объект. Всё это выглядит не таким уж и сложным, поэтому я могу выразить это в следующем коде:
Python:
Please, Log in or Register to view codes content!

Однако, этот тест, по своей природе, связан с глобальным состоянием. Сначала я заполняю кеш ZoneInfo посредством основного конструктора, затем я обращаюсь к нему через некий механизм, используемый pickle.loads. Что если подобное действие приведёт нашу систему в странное состояние? Что если так, как надо, работает только первый промах кеша, а последующие промахи работают как-то иначе? Для того чтобы это проверить — я могу написать второй тест:
Python:
Please, Log in or Register to view codes content!

Можно заметить, что, до второго вызова pickle.loads, это — тот же самый тест: я устанавливаю то же самое состояние! Если бы мы добавили во второй тест self.assertIs(zi_in, zi_rt), я смог бы одновременно выполнить оба теста, но это нарушило бы правило «одно утверждение на тест». Я ведь тестирую две разные сущности, делаться это должно в двух разных тестах. Подтесты разрешают эту дилемму, позволяя отмечать разделы теста с несколькими утверждениями как логически разделённые тесты:
Python:
Please, Log in or Register to view codes content!

Обратите внимание на то, что я исключил из контекстов подтеста вызовы pickle.loads. Это так из-за того, что, если иногда подтесты завершаются неудачно, выполняется оставшаяся часть теста. Если zi_in и zi_rt не являются идентичными объектами, это не мешает быть идентичными объектам zi_rt и zi_rt2. Поэтому имеет смысл выполнять оба теста. Но если не удаётся сконструировать zi_rt или zi_rt2, тесты, в которых с ними работают, неизбежно завершатся неудачно.

Минусы подтестов​

Мне не хотелось бы описывать текущее состояние дел в сфере подтестов, говоря о них в слишком оптимистичных выражениях. Я считаю, что в этой концепции скрыт огромный потенциал, но дьявол, как говорится, кроется в деталях. Я использовал подтесты, в основном, как часть реализации
Please, Log in or Register to view URLs content!
, и, в более общем виде, выполняя исследования для этого материала. В ходе работы я наткнулся на несколько довольно-таки весомых контраргументов, касающихся использования подтестов.

Подсчёт тестов выглядит странным​

При использовании для параметризации тестов механизма, основанного на декораторах, общее количество тестовых случаев, которые будут запускаться, определяется до запуска первого теста. Поэтому, если только разработчик сам не изменит количество тестов, в сообщениях о количестве выполненных тестов будет выводиться одно и то же. А при использовании подтестов, концепция того, что собой представляет один «тест», может оказаться довольно-таки странной. Взгляните на следующий простой тест:
Python:
Please, Log in or Register to view codes content!

Этот код можно счесть соответствующим 3 тестам — по одному для каждого подтеста. Или его можно рассматривать как 4 теста — один на каждый подтест и один для самого тестового случая (который может завершиться с ошибкой за пределами подтеста). Ещё этот код можно видеть как один тест, который либо завершается удачно, либо — неудачно, что зависит от того, завершатся ли неудачно все подтесты. Похоже, что и pytest, и unittest рассматривают этот код как один тест. Когда я запускаю pytest, мне выдаётся результат 1 passed in 0.01s (хотя я вижу 4 пройденных теста, применяя команду pytest -v, поэтому ситуация тут получается довольно сложная). Что произойдёт, если я изменю шаблон отказов?
Python:
Please, Log in or Register to view codes content!

Теперь получается довольно странный результат: 1 failed, 1 passed in 0.04s. Мы перешли от 1 теста к 2 тестам. А использование команды pytest -v приводит к сообщению о 3 успешно пройденных тестах и об 1 отказавшем. Аналогично, если я просто пропущу подтест, выдаются раздельные сообщения о неудачных и удачных прохождениях испытаний:
Python:
Please, Log in or Register to view codes content!

Тут получен такой результат: 1 failed, 1 passed, 1 skipped in 0.04s. А при использовании конструкции pytest -v система, как и прежде, сообщает о 4 тестах. Поэтому это — уже кое-что, но даже такая схема работы не является полностью стабильной, так как неудачное завершение теста может произойти за пределами контекста подтеста, что приведёт к преждевременному завершению теста:
Python:
Please, Log in or Register to view codes content!

Прогон этого теста приводит к интересным сообщениям. Вывод pytest -v выглядит так:
Python:
Please, Log in or Register to view codes content!

Но в итоговом сообщении говорится 1 failed, 0 passed in 0.04s. Получается, что у нас имеется один подтест, завершившийся удачно, но весь тест завершился неудачно, поэтому количество пройденных тестов, показатель passed, равняется 0.

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

Если вас такое положение дел совершенно не устраивает — вот открытое
Please, Log in or Register to view URLs content!
в репозитории pytest-subtests, там идёт дискуссия о том, каким должно быть правильное поведение системы.

Такой подход может легко привести к появлению спама​

Я рассчитываю на то, что реализация
Please, Log in or Register to view URLs content!
(Я уже достаточно много раз сказал о том, что работаю над реализацией PEP 615?) будет, в итоге, интегрирована в CPython (и поэтому я не могу использовать для параметризации pytest). Поэтому я, в наборе тестов для PEP 615, для простой параметризации тестов, использую подтесты. В моих тестах имеется испытание множества пограничных случаев. Это может оказаться весьма неприятным в достаточно часто встречающихся случаях, когда я делаю ошибку, которая ломает всё, а не только один-два механизма, соответствующих пограничным случаям.

Ситуацию усугубляет тот факт, что pytest -x, похоже, останавливает наборы тестов только после выполнения всех подтестов, вместо того, чтобы делать это после отказа первого подтеста. Это — проблема, решить которую сложнее, чем кажется. Это так из-за вышеописанной странности в подсчёте количества тестов. Какое определение «теста» использовать для --max-fail?

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

Плохое взаимодействие с другим функционалом​

Я уже упоминал о том, что у pytest -x (и у pytest --max-fail) имеются некоторые базовые проблемы, касающиеся интерфейса работы с подтестами. Но существует множество других инструментов тестирования кода, множество других возможностей таких инструментов, которые не рассчитаны на поддержку подтестов. Например, ещё одна проблема с pytest-subtests заключается в том, что сейчас pytest --pdb, похоже,
Please, Log in or Register to view URLs content!
при отказе подтеста. Аналогично, я обнаружил, что pytest.xfail() совершенно
Please, Log in or Register to view URLs content!
в подтесте.

Ещё я выяснил, что unittest.TestCase.subTest
Please, Log in or Register to view URLs content!
, но (помимо предупреждения, которое я считаю, в целом, необоснованным), фикстура subtests, предоставляемая pytest-subtests, похоже, работает нормально (я, правда, не пользовался pytest -v, так как это приводит к генерированию множества подтестов).

Даже в стандартной библиотеке имеются некоторые старые проблемы. Например — в Python 3.8.1:
Python:
Please, Log in or Register to view codes content!

Мне казалось, что эта конструкция сообщит о некоторых пройденных и некоторых пропущенных тестах, как было при использовании pytest-subtests, а, на самом деле, были выведены сведения лишь о пропущенных тестах:
Python:
Please, Log in or Register to view codes content!

Очевидно то, что нужно работать над дальнейшей интеграцией этой возможностью с другими, но я рассматриваю это, в основном, как симптом того факта, что подтесты — это возможность, о которой знают немногие, которую пока используют не особенно широко. Поэтому об ошибках, подобной этой, никто не сообщает, такие ошибки остаются неисправленными. Репозиторий
Please, Log in or Register to view URLs content!
(на момент написания этого текста) имеет лишь 51 звезду на GitHub, проект всё ещё находится на ранней стадии разработки. Думаю, что все эти проблемы будут решены после более широкого внедрения этого механизма в реальную работу, по мере того, как больше людей будет делать вклад в этот проект. Обратите внимание на то, что создатель этого модуля, Бруно Оливейра,
Please, Log in or Register to view URLs content!
в Твиттере, что над этим проектом ещё нужно поработать, и в своём отзыве на ранний черновик этого материала он предложил об этом упомянуть.

Итоги​

Это, на данный момент — самая моя длинная и пространная статья, в ней я касаюсь многих вещей. Поэтому я полагаю, что должен подвести итоги тому, о чём говорил.

Вот несколько типичных ситуаций, в которых оправдано применение подтестов:

  1. Их можно использовать там, где нужна параметризация тестов, но при этом нельзя пользоваться pytest. Или в тех случаях, когда нужно сгенерировать тестовые случаи так, чтобы область их видимости была бы ограничена текущим выполняемым тестом.
  2. Они пригодятся тогда, когда получение ресурса оказывается затратной операцией, но при этом нужно проверить более чем одно его свойство. В таких ситуациях подтесты дают нам простой в использовании и понимании механизм для логического разделения тестов.
  3. Они подойдут тогда, когда нужно проверять состояние системы по мере её развития. Подтесты позволяют проверять утверждения, когда неудачные тесты не приводят к остановке тестирования. Поэтому можно воспользоваться сильными сторонами наличия множества тестов, не тратя при этом время на установку множества шаблонных параметров состояния.
Вот главные обнаруженные мной минусы подтестов:

  1. При применении подтестов подсчёт «тестов» или «неудачных тестов» выглядит достаточно странно.
  2. Если сбои в коде связаны друг с другом — легко столкнуться со спамом в виде миллионов результатов.
  3. Различные инструменты для тестирования кода пока не очень хорошо интегрированы с подтестами. Поэтому при их совместном использовании всё ещё появляется множество ошибок, связанных с реализацией этих инструментов и самих подтестов.
Я должен сказать, что смотрю в будущее подтестов с оптимизмом — идёт ли речь о реализации стандартной библиотеки, о pytest-subtests, или о других библиотеках для тестирования кода на наличие ошибок, не препятствующих его работе, о которых я даже не говорил, вроде
Please, Log in or Register to view URLs content!
.

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

Стоит отметить, что в момент написания этого материала библиотека
Please, Log in or Register to view URLs content!
всё ещё находится на ранней стадии разработки (в PyPI она имеет версию 0.3.0). Дополнительные усилия, приложенные к работе над ней, сгладят её шероховатости. Если этот материал и соответствующий ему подкаст пробудили в вас интерес к подтестам — возможно, вы захотите сделать вклад в разработку
Please, Log in or Register to view URLs content!
и поучаствовать в развитии идеи подтестов.

Автор оригинала:
Please, Log in or Register to view URLs content!
 
Back
Top