[Перевод] Подтесты в Python

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

c1910c3bb533533e139aba81ad84750a.png

Или, что скорее всего так и есть, ему просто хотелось пригласить кого-то, с кем можно поговорить об этом конкретном аспекте Python-тестирования, а я оказался одним из тех немногих, встретившихся ему, кто высказывал по этому поводу своё мнение. В любом случае, этот пост будет играть роль моих заметок по механизму подтестов из unittest, который появился в Python 3.4. Здесь же пойдёт речь о сильных и слабых сторонах подтестов, о сценариях их использования. Этот материал можно считать дополнением к подкасту Test and Code Episode 111.

Введение

Механизм unittest.TestCase.subTest появился в Python 3.4, это был простой инструмент для параметризации тестов. Изначальную дискуссию, посвящённую ему, можно почитать в трекере проблем Python, в ветке bpo-16997. Там, в основном, речь идёт о деталях реализации, но там можно найти и интересные рассуждения. Этот механизм позволяет оформлять разделы тестов в виде отдельных тестов, действующих самостоятельно, с использованием менеджера контекста. Эталонным примером использования subTest является тестирование чего-либо в цикле:

def test_loop(self):
    for i in range(5):
        with self.subTest("Message for this subtest", i=i):
            self.assertEqual(i % 2, 0)

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

______ Test.test_loop [Message for this subtest] (i=1) ___

self = 

    def test_loop(self):
        for i in range(1, 5):
            with self.subTest("Message for this subtest", i=i):
>               self.assertEqual(i % 2, 0)
E               AssertionError: 1 != 0

test.py:7: AssertionError

______ Test.test_loop [Message for this subtest] (i=3) ___

...

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

Пользователям pytest возможности параметризации, которые даёт subTest, не покажутся невероятно привлекательными, так как в их распоряжении уже имеется несколько подобных механизмов. Среди них — pytest.mark.parametrize,  параметризованные фикстуры и довольно-таки таинственный хук pytest_generate_tests в conftest.py. Даже во фреймворке Google absltest (который, в основном, даёт незначительное расширение возможностей unittest) имеется декоратор для параметризации тестов.

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

def test_expensive_setup(self):
    resource = self.get_expensive_resource()

    for query, expected_result in self.get_test_cases():
        with self.subTest(query=query):
            self.assertEqual(resource.make_query(query), expected_result)

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

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

from datetime import *
from zoneinfo import ZoneInfo

import pytest

def datetime_test_cases():
    GMT = ("GMT", timedelta(0))
    BST = ("BST", timedelta(1))
    zi = ZoneInfo("Europe/London")

    return [
        (datetime(2020, 3, 28, 12, tzinfo=zi), GMT),
        (datetime(2020, 3, 29, 12, tzinfo=zi), BST),
        (datetime(2020, 10, 24, 12, tzinfo=zi), BST),
        (datetime(2020, 10, 25, 12, tzinfo=zi), GMT),
    ]

@pytest.mark.parametrize("dt, offset", datetime_test_cases())
def test_europe_london(subtests, dt, offset):
    exp_tzname, exp_utcoffset = offset
    with subtests.test(msg="tzname", dt=dt):
        assert dt.tzname() == exp_tzname

    with subtests.test(msg="utcoffset", dt=dt):
        assert dt.utcoffset() == exp_utcoffset

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

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

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

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

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

def test_cache_hit(self):
    zi_in = ZoneInfo("Europe/Dublin")
    pkl = pickle.dumps(zi_in)
    zi_out = pickle.loads(pkl)

    self.assertIs(zi_in, zi_out)

Однако, этот тест, по своей природе, связан с глобальным состоянием. Сначала я заполняю кеш ZoneInfo посредством основного конструктора, затем я обращаюсь к нему через некий механизм, используемый pickle.loads. Что если подобное действие приведёт нашу систему в странное состояние? Что если так, как надо, работает только первый промах кеша, а последующие промахи работают как-то иначе? Для того чтобы это проверить — я могу написать второй тест:

def test_cache_hit_twice(self):
    zi_in = ZoneInfo("Europe/Dublin")
    pkl = pickle.dumps(zi_in)
    zi_rt = pickle.loads(pkl)
    zi_rt_2 = pickle.loads(pkl)

    self.assertIs(zi_rt, zi_rt2)

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

def test_cache_hit(self):
    zi_in = ZoneInfo("Europe/Dublin")
    pkl = pickle.dumps(zi_in)

    zi_rt = pickle.loads(pkl)
    with self.subTest("Round-tripped is non-pickled zoneinfo"):
        self.assertIs(zi_in, zi_rt)

    zi_rt2 = pickle.loads(pkl)
    with self.subTest("Round-trip is second round-tripped zoneinfo"):
        self.assertIs(zi_rt, zi_rt2)

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

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

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

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

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

def test_a_loop(subtests):
    for i in range(0, 6, 2):
        with subtests.test(i=i):
            assert i % 2 == 0

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

def test_a_loop(subtests):
    for i in range(3):
        with subtests.test(i=i):
            assert i % 2 == 0

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

def test_a_loop(subtests):
    for i in range(3):
        with subtests.test(i=i):
            if i == 2:
                pytest.skip()

            assert i % 2 == 0

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

def test_a_loop(subtests):
    for i in range(3):
        if i == 1:
            pytest.fail()

        with subtests.test(i=i):
            assert i % 2 == 0

Прогон этого теста приводит к интересным сообщениям. Вывод pytest -v выглядит так:

test.py::test_a_bunch_of_stuff PASSED
test.py::test_a_bunch_of_stuff FAILED

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

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

Если вас такое положение дел совершенно не устраивает — вот открытое сообщение об ошибке в репозитории pytest-subtests, там идёт дискуссия о том, каким должно быть правильное поведение системы.

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

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

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

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

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

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

Ещё я выяснил, что unittest.TestCase.subTest не работает с Hypothesis, но (помимо предупреждения, которое я считаю, в целом, необоснованным), фикстура subtests, предоставляемая pytest-subtests, похоже, работает нормально (я, правда, не пользовался pytest -v, так как это приводит к генерированию множества подтестов).

Даже в стандартной библиотеке имеются некоторые старые проблемы. Например — в Python 3.8.1:

def test_loop(self):
    for i in range(5):
        with self.subTest(i=i):
            if i % 2:
                self.skipTest("Skipping odd")
            self.assertEqual(i % 2, 0)

Мне казалось, что эта конструкция сообщит о некоторых пройденных и некоторых пропущенных тестах, как было при использовании pytest-subtests, а, на самом деле, были выведены сведения лишь о пропущенных тестах:

test.py::Test::test_loop SKIPPED    [100%]
test.py::Test::test_loop SKIPPED    [100%]

============ 2 skipped in 0.02s ==========

Очевидно то, что нужно работать над дальнейшей интеграцией этой возможностью с другими, но я рассматриваю это, в основном, как симптом того факта, что подтесты — это возможность, о которой знают немногие, которую пока используют не особенно широко. Поэтому об ошибках, подобной этой, никто не сообщает, такие ошибки остаются неисправленными. Репозиторий pytest-subtests (на момент написания этого текста) имеет лишь 51 звезду на GitHub, проект всё ещё находится на ранней стадии разработки. Думаю, что все эти проблемы будут решены после более широкого внедрения этого механизма в реальную работу, по мере того, как больше людей будет делать вклад в этот проект. Обратите внимание на то, что создатель этого модуля, Бруно Оливейра,  сказал в Твиттере, что над этим проектом ещё нужно поработать, и в своём отзыве на ранний черновик этого материала он предложил об этом упомянуть.

Итоги

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

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

  1. Их можно использовать там, где нужна параметризация тестов, но при этом нельзя пользоваться pytest. Или в тех случаях, когда нужно сгенерировать тестовые случаи так, чтобы область их видимости была бы ограничена текущим выполняемым тестом.

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

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

Вот главные обнаруженные мной минусы подтестов:

  1. При применении подтестов подсчёт «тестов» или «неудачных тестов» выглядит достаточно странно.

  2. Если сбои в коде связаны друг с другом — легко столкнуться со спамом в виде миллионов результатов.

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

Я должен сказать, что смотрю в будущее подтестов с оптимизмом — идёт ли речь о реализации стандартной библиотеки, о pytest-subtests, или о других библиотеках для тестирования кода на наличие ошибок, не препятствующих его работе, о которых я даже не говорил, вроде pytest-check.

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

Стоит отметить, что в момент написания этого материала библиотека pytest-subtests всё ещё находится на ранней стадии разработки (в PyPI она имеет версию 0.3.0). Дополнительные усилия, приложенные к работе над ней, сгладят её шероховатости. Если этот материал и соответствующий ему подкаст пробудили в вас интерес к подтестам — возможно, вы захотите сделать вклад в разработку pytest-subtests и поучаствовать в развитии идеи подтестов.

О, а приходите к нам работать?

© Habrahabr.ru