Основы эффективного тестирования Python программ в Pytest Python

Основы эффективного тестирования Python программ в Pytest

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

Естественно, качество тестирования конкретной Python разработки (программы) напрямую зависит от того, насколько точно и всеобъемлюще в создаваемых нами под нее тестах будет смоделирована среда запуска этой программы, номенклатура обрабатываемых ею данных, многовариантная система взаимодействия компонентов этой программы, поведение эксплуатирующих ее пользователей и, многое, многое другое. Следовательно, создание и поддержание тестов является крайне сложным, многообразным и трудоемким процессом, который с целью своего упрощения требует использования самого разнообразного программного инструментария.

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

Непреложным свидетельством популярности Pytest, в частности, может выступать то, что подавляющее большинство интернет-проектов, включая Mozilla и Dropbox, уже полностью переключились с Unittest или Nose на Pytest. Данный факт обусловлен тем, что пакет Pytest, в отличии от любых других систем тестирования, обладает мощным многопрофильным функционалом, к арсеналу которого, в частности, можно причислить перезапись «assert», сторонние плагины, использования фикстур, а также многое и многое другое.

Данная статья поможет нам разобраться в том:

  • Какими преимуществами привлекает Pytest своих пользователей
  • Как в тестах смоделировать надлежащие условия (состояния) для выполнения программ
  • Как сжать и сделать более понятным код для повторяющихся тестов
  • Как запустить поднабор тестов исходя из указываемой метки (категории) или имени директории
  • Как оптимизировать тесты для их универсализации и многократного применения

Установка Pytest

Для практического закрепления материала и запуска примеров из этой статьи нам потребуется предварительно установить Pytest. Это можно осуществить инсталлировав соответствующее ПО из каталога публичных Python дополнений PyPI с помощью стандартной системы управления пакетами pip, использовав следующую команду:

python -m pip install pytest

Данную инсталляцию желательно производить в заранее подготовленной виртуальной среде, что позволит ограничить сферу действия Pytest лишь рамками конкретно заданного нами проекта. Такую виртуальную среду с последующей установкой Pytest для операционной среды Windows, например, можно создать следующими командами:

PS> python -m venv venv
PS> .\venv\Scripts\activate
(venv) PS> python -m pip install pytest

Основные преимущества Pytest в сравнении с другими тестовыми платформами

Если у вас когда-либо возникала потребность в модульном тестировании своих Python программ, то наверняка вам уже приходилось пользоваться встроенным в этот интерпретатор инструментом Unittest. Он имеет довольно неплохой арсенал средств для создания различных наборов тестов, но вместе с тем обладает целым рядом недостатков. Улучшить Unittest и устранить большинство его недостатков в своих альтернативных версиях пыталось множество сторонних тестовых платформ. Однако, лидерство по популярности среди всех этих версий безусловно принадлежит фреймворку Pytest, поскольку именно он наиболее удачно объединяет в себе базовую многофункциональность с внушительным перечнем дополнений в виде плагинов.

Несомненно, освоив Pytest и начав его использовать, вы будете получать от этого большое удовольствие. Философия данного пакета, его базовый инструментарий и расширяемые возможности делают процесс тестирования более продуктивным и приятным. С Pytest обычные задачи требуют меньшего количества кода, а продвинутые решаются с применением блочных команд и плагинов. Более того, Pytest изначально может запускать старые, уже имеющиеся у вас тесты (в том числе написанные в Unittest) без какой-либо их предварительной модификации.

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

Минимизация шаблонного кода, требуемого для описания тестов

Как правило, большая часть тестов, специализирующихся на проверки работоспособности четко заданного куска кода ПО (функциональных тестов), основывается на применении модели «Подготовка – Действие – Проверка» (Arrange, Act, Assert):

  1. Подготовка условий для проведения тестирования
  2. Действие, выполняющее тестируемую функцию или метод
  3. Проверка результата от выполнения Действия на соблюдения некоторого конечного условия.

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

Попробуем это продемонстрировать на примере работы с вышеназванным пакетом. Допустим, с целью проверки правильности работы Unittest для нашего проекта, нам потребуется написать один тест, который всегда будет пройден, и один тест, который всегда будет терпеть неудачу:

# Код для Unittest в файле "test_for_unittest.py"

from unittest import TestCase

class TryTesting(TestCase):
    def test_vsegda_prokhodit(self):
        self.assertTrue(True)
    def test_vsegda_terpit_neudachu(self):
        self.assertTrue(False)

После этого, запустим приведенные в этом примере тесты через командную строку, используя опцию discover в Unittest:

(venv) PS > python -m unittest discover
.F
======================================================================
FAIL: test_vsegda_terpit_neudachu (test_for_unittest.TryTesting)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "…\test_for_unittest.py", line 9, in test_vsegda_terpit_neudachu
    self.assertTrue(False)
AssertionError: False is not true
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)

Примечание: Опция discover в Unittest обнаруживает и запускает наборы тестов, размещенные в файлах c расширением .py внутри вашего проекта. Причем, для Unittest не важно, как эти файлы с тестами будут именоваться, поскольку он анализирует все файлы проекта исходя из их содержимого. Pytest же производить поиск наборов тестов, прежде всего, по имени .py файлов, которое обязательно должно начинаться со слова test и нижнего подчеркивания (test/_). Данное различие может привести к тому, что те наборы тестов, которые для Unittest были записаны в файлах с нестандартными именами, не смогут быть обработаны Pytest.

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

  1. Импортировать класс TestCase из Unittest.
  2. Создать из этого импортированного класса TestCase подкласс TryTesting.
  3. Написать в TryTesting метод для каждого нашего теста.
  4. Воспользоваться одним из методов self.assert* из unittest.TestCase для задания условий проверки.

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

# Код для Pytest в файле "test_for_pytest.py"

def test_vsegda_prokhodit():
    assert True

def test_vsegda_terpit_neudachu():
    assert False

Не правда ли, по сравнению с кодом для тестов в Unittest, этот код намного компактнее и понятнее. Здесь не нужно возиться с импортами и классами, а при использовании ключевого слова assert нам не придется держать в голове массу методов self.assert*, как это было при работе в Unittest. Тут достаточно лишь написать логическое выражение, оцениваемое как True, и Pytest запросто его считает и протестирует.

Мало того, что Pytest избавляет нас от многократного ввода больших объемов тривиального кода, он еще и по результатам проведенных тестов формирует гораздо более подробные и удобочитаемые (в сравнении с другими фреймворками) итоговые отчеты.

Подробные и наглядные отчеты по результатам проведенных тестов

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

(venv) PS > pytest
============================= test session starts =============================
platform win32 -- Python 3.11.0a3, pytest-7.1.2, pluggy-1.0.0
rootdir: \…\testirovaniye-s-pomoshchyu-pytest
collected 4 items

test_for_pytest.py .F                                                    [ 50%]
test_for_unittest.py .F                                                  [100%]

================================== FAILURES ===================================
________________________ test_vsegda_terpit_neudachu __________________________

    def test_vsegda_terpit_neudachu():
>       assert False
E       assert False
test_for_pytest.py:7: AssertionError

_________________ TryTesting.test_vsegda_terpit_neudachu ______________________

self = <test_for_unittest.TryTesting testMethod=test_vsegda_terpit_neudachu>
    def test_vsegda_terpit_neudachu(self):
>       self.assertTrue(False)
E       AssertionError: False is not true
test_for_unittest.py:9: AssertionError

========================= short test summary info =============================
FAILED test_for_pytest.py::test_vsegda_terpit_neudachu - assert False
FAILED test_for_unittest.py::TryTesting::test_vsegda_terpit_neudachu -
       AssertionError: False is not true
======================= 2 failed, 2 passed in 0.14s ===========================

Как мы видим, при запуске Pytest без опций, он выполняет абсолютно все наборы тестов (в том числе подготовленные и для Unittest), размещенные в нашем проекте. Но, это осуществимо лишь в том случае, если имена файлов с наборами тестов, как для Pytest, так и для Unittest будут начинаться с test/_.

Результаты тестирования в Pytest отображаются несколько иначе, чем в Unittest. В частности, данный пакет формирует более подробный отчет, первый раздел которого показывает:

  1. Состояние системы, включая версии Python, Pytest и всех установленных в системе (проекте) плагинов.
  2. Корневой каталог проекта rootdir или каталог, где собраны тесты и их конфигурации.
  3. Количество обнаруженных в проекте тестов.
============================= test session starts =============================
platform win32 -- Python 3.11.0a3, pytest-7.1.2, pluggy-1.0.0
rootdir: \…\testirovaniye-s-pomoshchyu-pytest
collected 4 items

Затем в выводе этого отчета отображается результат от выполнения каждого теста, закодированный в тех же обозначениях, что и для Unittest:

  • Точка (.) означает, что тест пройден.
  • Буква F означает, что тест не пройден.
  • Буква E означает, что тест вызвал непредвиденное исключение.

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

test_for_pytest.py .F                                                    [ 50%]
test_for_unittest.py .F                                                  [100%]

Для тестов, потерпевших неудачу, в выводимом Pytest отчете приводится подробное описание возникших ошибок. Так, в нашем примере два теста потерпели неудачу из-за того, что утверждение self.assertTrue(False) для Unittest и утверждение assert False для Pytest всегда будут ошибочными.

================================== FAILURES ===================================
________________________ test_vsegda_terpit_neudachu __________________________

    def test_vsegda_terpit_neudachu():
>       assert False
E       assert False
test_for_pytest.py:7: AssertionError

_________________ TryTesting.test_vsegda_terpit_neudachu ______________________

self = <test_for_unittest.TryTesting testMethod=test_vsegda_terpit_neudachu>
    def test_vsegda_terpit_neudachu(self):
>       self.assertTrue(False)
E       AssertionError: False is not true
test_for_unittest.py:9: AssertionError

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

========================= short test summary info =============================
FAILED test_for_pytest.py::test_vsegda_terpit_neudachu - assert False
FAILED test_for_unittest.py::TryTesting::test_vsegda_terpit_neudachu -
       AssertionError: False is not true
======================= 2 failed, 2 passed in 0.14s ===========================

Легкость в освоении тестового фреймворка

Использование в Pytest ключевого слова assert безусловно может являться чуть ли не основным залогом простоты написания и легкости восприятия кода создаваемых в данном фреймворке тестов. Ведь конструкции с этим ключевым словом, применяемые при формировании утверждений для тестов, очень схожи на обычные функции Python и, следовательно не требуют изучения каких-то новых конструкций, как это было при использовании Unittest.

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

# test_proverok_utverzhdeniy.py

# Тест на правильность преобразования текста в верхний регистр
def test_uppercase():
    assert "преобразование в верхний регистр".upper() == \
           "ПРЕОБРАЗОВАНИЕ В ВЕРХНИЙ РЕГИСТР"

# Тест на правильность преобразования последовательности
# в ее обратный аналог
def test_obratnyy_iterator():
    assert list(reversed([1, 2, 3, 4])) == [4, 3, 2, 1]

# Тест на наличие некоторого простого числа в заданном диапазоне
# простых и составных чисел
def test_some_primes():
    assert 37 in {
        num
        for num in range(2, 50)
        if not any(num % div == 0 for div in range(2, num))
    } 

Таким образом, на освоение Pytest, по сравнению с Unittest, требуется гораздо меньше времени, а сам код тестов, написанных под Pytest будет понятен даже новичкам, не знакомым с этим тестовым фреймворком.

Простое моделирование среды запуска и взаимодействия тестов

Результаты наших тестов напрямую зависят от того, насколько корректно (полно) в них будут учтены структуры данных (словари, JSON файлы и т.п), и тестовые дубли (функции, классы, процедуры и т.д.), моделирующие среду для запуска того реального ПО, для которого эти тести предназначаются. В Unittest нужная нам среда формируется с помощью методов setUp() и tearDown(), позволяющих затем использовать смоделированные структуры данных и тестовые дубли во всех тестах соответствующего класса. В то же время использование вышеназванных методов чревато тем, что с разрастанием тестового класса, где они применяются, все труднее становиться уловить, как именно отдельно взятый тест взаимосвязан с остальными тестами этого класса.

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

В отличии от Unittest, Pytest благодаря фикстурам (fixtures) позволяет явно объявлять все необходимые нам элементы для моделирования среды запуска и взаимодействия тестов, оставляя при этом такие элементы доступными для повторного использования. Под Фикстурами в Pytest подразумеваются функции, создающие структуры данных и тестовые дубли, а также устанавливающие определенное состояние у операционных систем с целью моделирования необходимой нам среды для запуска и взаимодействия соответствующих наборов тестов. Для использования фикстур в тестах, они должны быть явно приняты ими в виде аргументов. Именно поэтому, все создаваемые фикстурами элементы моделируемой нами среды всегда должны определяться перед запускаемыми в этой среде тестами.

# fixture_primer.py

import pytest

@pytest.fixture
def primer_fixture():
    return 1

def test_for_fixture(primer_fixture):
    assert primer_fixture == 1 

Как видно из данного примера, функции фикстур в Pytest обертываются в специальный декоратор @pytest.fixture.

Примечание: В контексте применения декораторов нужно отметить, что большая часть функциональности в Pytest как раз и реализована с помощью данного инструментария, применение которого для новичков может показаться несколько запутанным и отталкивающим. Но, на самом деле, использование декораторов в Python является очень мощным и не имеющим аналогов средством для актуализации и самосовершенствования его кода. К тому же применение и понимание сути действия этих декораторов не так уж сложно, как может показаться на первый взгляд. Более подробно об аспектах и специфики применения декораторов при кодинге вы можете узнать из статьи «Декораторы, как средство для самосовершенствования кода в Python».

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

Примечание: В проекте, размещаемые в Python файлах, наборы тестов для соответствующего ПО обычно объединяются в отдельную папку под названием tests, создаваемую на корневом уровне данного проекта.

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

Легкий запуск тестовых наборов исходя из заданных критериев

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

  • Фильтрация по имени – дает возможность посредством применения параметра -k запускать в фреймворке только те тесты, полное имя которых совпадает с заданным нами выражением.
  • Ограничение заданным каталогом – обеспечивает в Pytest запуск лишь тех тестов, которые либо размещены в заданном нами каталоге, либо находятся ниже в подкаталогах данного каталога.
  • Категоризация тестов – дает возможность с помощью применения параметра -m запускать лишь те тесты, категория (метка) которых, указываемая для них при объявлении, совпадает с меткой заданной нами в командной строке Pytest.

Категоризация тестов в Pytest – это весьма мощный и точный инструмент, основанный на возможности создания для тестов своеобразных пользовательских ярлыков – маркеров (marks), по-русски, зачастую называемых метками. Каждый объявляемый нами тест с помощью вышеупомянутого в данной статье механизма декорирования может иметь сразу несколько меток, задаваемых тесту в виде его соответствующих декораторов (любых названий меток, которые начинаются с @pytest.mark.). Благодаря этому один и тот же тест можно использовать в формировании и запуске самых разнообразных по целям, задачам и объектам приложения, тестовых наборах. Ниже в данной статье будет приведено несколько примеров работы с метками Pytest в условиях наличия большого количества тестов.

Возможность параметризации тестов

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

Для решения вышеописанной проблемы, Unittest позволяет объединять несколько тестов в один, но при этом в выводимых отчетах он не дает возможности просмотра результатов каждого из объединяемых тестов. Таким образом, если в группе объединяемых тестов один тест будет провален, а другой пройден, то эта группа все равно будет помечена как один проваленный тест. Pytest же предлагает иное решение, в котором результаты тестов учитываются независимо друг от друга посредством добавления к ним соответствующих параметров (параметризации). Далее в этой статье мы более подробно разберемся с тем, как проводить параметризацию тестов в рассматриваемом фреймворке.

Расширение функциональности за счет плагинов

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

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

Фикстуры, как средство моделирования среды для запуска тестов

Фикстуры в Pytest – это механизм применения специальных функций, позволяющих сформировать структуры данных, тестовые дубли и переменные операционных систем с целью моделирования среды запуска и работы тестируемого нами ПО. Специфика данных функций (фикстур) заключается в том, что требующие эти фикстуры тесты должны явно принимать их в себя в качестве аргументов. При этом данные аргументы рассматриваются тестами не как функции (фикстуры), а как значения, которые они призваны непосредственно возвращать.

Ситуации, при которых фикстуры являются обязательными

В этом разделе мы попытаемся смоделировать типичный, но максимально упрощенный, рабочий процесс разработки ПО через тестирование (TDD).

Предположим, что в рамках разработки соответствующего ПО перед нами поставлена задача написать функцию format_data_vyvod() для обработки данных, предоставляемых с конкретных сайтов через их API. Допустим, что эти данные должны будут содержать список сотрудников дипломатических миссий, у каждого из которых есть имя, фамилия и должность. Эти сведения о дипломатах нам нужно представить в виде перечня строк, описывающих каждого сотрудника в следующем формате: [Имя Фамилия : (двоеточие) Должность]:

# format_data.py

def format_data_vyvod(diplomat):
    ...  # Действия внутри функции

В соответствии с классическими требованиями TDD для написания вышеобъявленной функции нам предварительно следовало бы написать для нее тест, код которого мог бы выглядеть следующим образом:

def test_format_data_vyvod():
    diplomat = [
        {
            "imya": "Василий",
            "familiya": "Назаренко",
            "dolzhnost": "Эксперт по вопросам местного законодательства",
        },
        {
            "imya": "Антон",
            "familiya": "Цуканов",
            "dolzhnost": "Эксперт по местным национальным меньшинствам",
        },
    ]

    assert format_data_vyvod(diplomat) == [
        "Василий Назаренко: Эксперт по вопросам местного законодательства",
        "Антон Цуканов: Эксперт по местным национальным меньшинствам",
    ]

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

# format_data.py

def format_data_vyvod(diplomat):
    ...  # Действия внутри функции

def format_data_excel(diplomat):
    ... # Действия внутри функции

Одним из преимуществ TDD подхода является то, что благодаря предварительному тестированию он помогает планировать саму разработку ПО наперед. Так, создавая тест для функции format_data_excel() мы видим, что он чрезвычайно похож на нашу первую функцию format_data_vyvod():

def test_format_data_excel():
    diplomat = [
        {
            "imya": "Василий",
            "familiya": "Назаренко",
            "dolzhnost": "Эксперт по вопросам местного законодательства",
        },
        {
            "imya": "Антон",
            "familiya": "Цуканов",
            "dolzhnost": "Эксперт по местным национальным меньшинствам",
        },
    ]

    assert format_data_excel(diplomat) == """Имя,Фамилия,Должность
    Василий,Назаренко,Эксперт по вопросам местного законодательства
    Антон,Цуканов,Эксперт по местным национальным меньшинствам
    """

Примечательно, что в каждом из двух тестов мы абсолютно идентичным образом объявляем переменную diplomat, определение которой в этих тестах занимает довольно много строк кода. Вышеописанная ситуация с такими повторяющимися определениями данных является первым признаком необходимости применения для тестов соответствующих фикстур, которые за счет оборачивания дублируемого кода (определения) в декоратор @pytest.fixture позволяют оформлять его в виде отдельной функции:

# test_format_data.py

import pytest

@pytest.fixture
def svedeniya_o_diplomatakh():
    return [
        {
            "imya": "Василий",
            "familiya": "Назаренко",
            "dolzhnost": "Эксперт по вопросам местного законодательства",
        },
        {
            "imya": "Антон",
            "familiya": "Цуканов",
            "dolzhnost": "Эксперт по местным национальным меньшинствам",
        },
    ]

# ...

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

def test_format_data_vyvod(svedeniya_o_diplomatakh):
    assert format_data_vyvod(svedeniya_o_diplomatakh) == [
        "Василий Назаренко: Эксперт по вопросам местного законодательства",
        "Антон Цуканов: Эксперт по местным национальным меньшинствам",
    ]

def test_format_data_excel(svedeniya_o_diplomatakh):
    assert format_data_excel(svedeniya_o_diplomatakh) == """Имя,Фамилия,Должность
        Василий,Назаренко,Эксперт по вопросам местного законодательства
        Антон,Цуканов,Эксперт по местным национальным меньшинствам
        """

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

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

Ситуации, когда применение фикстур не рекомендуется

Фикстуры отлично подходят для обособления в отдельные функции тех определений структур данных или программных объектов, которые используются сразу в нескольких тестах. Однако они не всегда хороши для тестов, работающих с незначительно изменёнными данными или объектами. Ведь тогда код наших тестов можно засорить массой однотипных (очень похожих) фикстур, что ничуть не лучше, засорения данного кода простыми данными или объектами. Скорее наоборот, данная ситуация может быть еще более пагубной из-за дополнительного уровня косвенности.

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

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

Использование фикстур в условиях разрастания проектов

Работая над фикстурами, у нас может возникнуть соблазн разбить некоторые из них на еще более мелкие модули (подфикстуры). Тут нет ничего удивительного, ибо большая часть структур данных и программных объектов, для которых мы создаем наши фикстуры имеют модульную природу. Вместе с тем дробление фикстур на более мелкие модули приводит к их взаимозависимости. Таким образом, можно столкнуться с тем, что фикстуры из двух различных наборов тестов имея общие зависимости не будут знать о них.

Примечание: Тут, как и везде в статье, под тестовом набором понимается отдельный Python файл, содержащий ряд тестов и имеющий имя, начинающиеся с test_.

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

Более того, если для фикстур общего назначения нам необходимо вообще избавиться от импорта и сделать их общедоступными в любом месте проекта, мы можем воспользоваться специальным модулем конфигурации conftest.py. Тогда, при каждом запуске Pytest будет искать этот модуль не только в корневом каталоге проекта, но и во всех его подкаталогах с тем, чтобы автоматически обеспечивать доступ из любого теста ко всем, хранящемся в модули conftest.py фикстурам. Таким образом, conftest.py, является отличным местом, где всегда можно разместить все наиболее часто используемые в проекте фикстуры.

Еще одним приятным бонусом использования conftest.py является возможность размещения там специальных фикстур для обеспечения защищенного доступа к сетевым ресурсам. Допустим, мы сформировали тестовый набор для проверки кода, работающего с API реального Internet ресурса. И, для устранения опасности случайного изменения данных в этом Internet ресурсе, нам требуется обеспечить гарантии того, что вышеназванный набор тестов никогда не сможет выполнять реальные сетевые вызовы. С этой целью Pytest предлагает свою системную специальную фикстуру monkeypatch, запрещающую через наши прикладные (собственноручно созданные) фикстуры в тестах осуществлять доступ к реальным сетевым ресурсам:

# conftest.py

import pytest
import requests

@pytest.fixture(autouse=True)
def otklyucheniye_setevykh_vyzovov(monkeypatch):
    def oshibochnyy_vyzov():
        raise RuntimeError("В процессе тестирования доступ к сети запрещен!")
    monkeypatch.setattr(requests, "get", lambda *args, **kwargs: stunted_get())

Разместив функцию фикстуры otklyucheniye_setevykh_vyzovov() в conftest.py и добавив в эту фикстуру опцию autouse=True, мы обеспечим отключение возможности выполнения сетевых вызовов для всех тестовых наборов. Таким образом, если какой-нибудь тест через код requests.get() выполнит вызов сетевого ресурса, то это вызовет ошибку RuntimeError, обозначающую непредвиденный сетевой вызов.

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

Категоризация тестов с помощью меток

При большом количестве уже наработанных нами тестов всегда хотелось бы иметь возможность запускать лишь некоторую их часть, например для тестирования только вновь появившейся в нашем ПО функциональности. Как всегда, постоянно находясь в режиме удовлетворения наших «хотелок», Paytest и на этот раз не ударил «лицом в грязь» предоставляя нам для реализации этой задачи свои широкие возможности в сфере использования маркеров (меток).

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

Категоризация (маркировка) тестов наиболее всего полезна для их классификации по объектам тестирования (тестовым дублям), функциям ПО или по источникам обрабатываемых данных. Например, если некоторые из наших тестов требуют доступа к базе данных, мы можем создать для них следующую метку: @pytest.mark.database_access.

Примечание: Любая, создаваемая вами метка для теста представляет собой декоратор, в который должен оборачиваться маркируемый вами тест. Имя данного декоратора всегда должно начинаться с @pytest.mark., после чего без пробелов может следовать любая необходимая вам метка. Поскольку имена меток могут быть любыми и их легко забыть или допустить ошибку при вводе, то Pytest всегда будет предупреждать вас об именах меток, которые прописанных лишь в виде декораторов тестов, но не зарегистрированы в самом фреймворке.

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

Все зарегистрированные в Pytest метки, хранящиеся в файле pytest.ini, вы можете посмотреть, запустив этот фреймворк в виде командной строки: pytest –markers.

Не взирая на то, что нами уже задана метка @pytest.mark.database_access, мы все еще можем с помощью команды pytest без опций запустить сразу все тесты нашего проекта. Однако если нам будут нужны только те из них, которым требуется доступ к базе данных, мы просто воспользуемся командой pytest -m "database_access". И, наоборот, если нам понадобится запустить все тесты, кроме тестов, работающих с базой данных, мы применим команду pytest -m "not database_access". Кроме того, для тестов с меткой database_access мы даже можем добавить параметризацию autouse=True, что позволит для таких тестов ограничить доступ к базе данных. Выглядеть наш декоратор для вышеназванной метки с параметризацией autouse=True будет следующим образом: @pytest.mark.database_access(autouse=True).

Кроме задаваемых нами собственноручно меток, в Pytest существуют плагины, которые автоматически создают метки для защиты доступа к тем или иным ресурсам. Например, плагин pytest-django создает метку django_db, благодаря которой все помеченные ею тесты, пытающиеся получить доступ к базе данных, будут всегда обречены на неудачу. Исключением при этом будет лишь первая попытка запуска какого-либо теста с вышеназванной меткой, когда в ходе выполнения данного теста инициируется создания новой тестовой базы данных Django.

Использование метки django_db полностью укладывается в философию Pytest, основанную на создании явных взаимозависимостей (обеспечении максимального взаимодействия) между тестами. Конкретным эффектом от этого, в частности, может являться то, что благодаря команде pytest -m "not django_db" мы сможем обходится без постоянного пересоздания тестовой базы данных, следовательно, будем способны намного быстрее запускать и выполнять тесты, не требующие доступа к базе данных. Все это будет существенно экономит наше время, особенно тогда, когда в рамках проекта нам требуется проведение очень частых тестирований.

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

  • skip пропускает тест при любых условиях.
  • skipif пропускает тест, если переданное метке выражение для данного теста оценивается как True.
  • xfail помечает тест, который ожидаемо должен быть провален. Использование данной метки приводит к тому, что если помеченный ею тест действительно будет провален, то весь тестовый набор все-равно сохранит статус пройденного, а Pytest по этому поводу не выдаст никакого сообщения.
  • parametrize создает несколько вариантов теста с разными значениями в качестве аргументов. Ниже в этой статье об этой метки будет рассказано более подробно.

Параметризация, как средство объединения тестов

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

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

def test_na_palindrom_pustoy_stroki():
    assert yavlyayetsya_palindromom("")

def test_na_palindrom_odnogo_simvola():
    assert yavlyayetsya_palindromom("а")

def test_na_obychnyy_palindrom ():
    assert yavlyayetsya_palindromom("довод")

def test_na_slogovyy_palindrom():
    assert yavlyayetsya_palindromom("Лихачи на всех начихали")

def test_na_palindrom_oboroten():
    assert yavlyayetsya_palindromom("Мир удобен")

def test_na_ne_palindrom():
    assert not yavlyayetsya_palindromom("абв")

def test_na_ne_absolyutnyy_palindrom():
    assert not yavlyayetsya_palindromom("авав")

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

def test_na_palindrom_<какого-то вида>():
    assert yavlyayetsya_palindromom("<какая-то строка>")

В подобных ситуациях с повторяющимися, но чуть различающимися в части тестируемых объектов, тестами мы можем воспользоваться специальной системной меткой Pytest @pytest.mark.parametrize(), позволяющей универсализировать такие объекты, задавая им соответствующие параметры и значительно сокращая тем самым код тестовых наборов.

@pytest.mark.parametrize("palindrom", [
    "",
    "а",
    "довод",
    "Лихачи на всех начихали",
    "Мир удобен",
])
def test_na_palindrom(palindrom):
    assert yavlyayetsya_palindromom(palindrom)

@pytest.mark.parametrize("ne_palindrom", [
    "абв",
    "авав",
])
def test_na_ne_palindrom(ne_palindrom):
    assert not yavlyayetsya_palindromom(ne_palindrom)

Как видно из данного примера, первым аргументом parametrize() должно являться имя непосредственно самого параметра, который должен затем передаваться в тесты, опять же уже в качестве их аргумента. Данный аргумент может содержать несколько имен параметров, разделенных запятой. Вторым аргументом вышеназванной системной метки является само значение параметра, имя которого мы уже указали в первом аргументе parametrize(). Этим вторым аргументом могут быть либо просто отдельные значения нашего параметра, либо последовательности данных в виде списков или кортежей.

Наконец, за счет применения все той же метки parametrize() мы можем еще более сократить и универсализировать наши тесты, объединив их в один:

@pytest.mark.parametrize("vozmozhnyy_palindrom, ozhidayemyy_rezultat", [
    ("", True),
    ("а", True),
    ("довод", True),
    ("Лихачи на всех начихали", True),
    ("Мир удобен", True),
    ("абв", False),
    ("авав", False),
])
def test_na_palindrom(vozmozhnyy_palindrom, ozhidayemyy_rezultat):
    assert yavlyayetsya_palindromom(vozmozhnyy_palindrom) == ozhidayemyy_rezultat

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

Борьба с медленными тестами посредством формирования специальных отчетов

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

Выше в данной статье мы уже познакомились с тем, как использовать метки для фильтрации медленных тестов при запуске тестовых наборов проекта. Но прежде, для использования соответствующих меток, а также для оптимизации кода медленных тестов, нам нужно отследить эти тесты в нашем проекте. С этой целью в Pytest можно использовать опцию --durations, позволяющую в отчет о результатах тестирования включить дополнительный раздел с подборкой наиболее медленных тестов. Данная опция --durations инициализируется целым числом n, указывающим максимально допустимое количество наиболее медленных тестов, которые Pytest должен будет вывести в своем отчете:

(venv) PS > pytest --durations=5
...
============================= slowest 5 durations =============================
3.03s call     test_code.py::test_zaprosa_taymauta
1.07s call     test_code.py::test_zaprosa_vremeni_na_soyedineniye
0.57s call     test_code.py::test_na_chteniye_iz_database

(2 durations < 0.005s hidden.  Use -vv to show these durations.)
=========================== short test summary info ===========================
...

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

Примечание: Как видно из приведенного примера, количество тестов, которые выводятся в дополнительном разделе отчета Pytest в качестве наиболее медленных, далеко не всегда совпадает с тем числом, которое вы указывали при инициализации опции --durations. Это объясняется тем, что Pytest показывает в соответствующем разделе своего отчета только те тесты, которые при своем выполнении затрачивают более 0,005 секунд и действительно требуют оптимизации.

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

Полезные плагины для Pytest

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

pytest-randomly

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

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

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

pytest-cov

Одним из основных показателей качества тестов априори может являться процент кода (функций, классов, структур данных и т.д.) соответствующего ПО, который был задействован в сформированных нами тестовых наборах. Данный показатель, который обычно трактуется как процент покрытия тестами кодовой реализации может быть измерен на основе применения пакета Coverage.py*, интегрируемого в среду тестирования с помощью специального плагина pytest-cov. Так, после установки данного плагина в качестве дополнения на Pytest у нас появится возможность просмотра дополнительного отчета о покрытии тестами кодовой реализации с помощью команды pytest --cov**.

(venv) PS > pytest --cov

---- coverage: platform win32 -- Python 3.11.0a3 ------
Name                      Stmts   Miss  Cover
-------------------------------------------------------
my_program.py                20      4    80% 
my_other_module.py           56      6    89%
-------------------------------------------------------
TOTAL                        76     10    87%

pytest-django

Плагин Pytest-django содержит несколько полезных фикстур и меток для работы с тестами, предназначенными для тестирования Django разработок. В этой статье нам уже встречалась метка django_db. Но, кроме нее, данный плагин предоставляет еще и ряд фикстур, наиболее популярными из которых является фикстура rf, обеспечивающая прямой доступ к экземпляру Django RequestFactory и фикстура settings позволяющая быстро устанавливать или переопределять настройки самого фреймворка Django. Вообще применение данного плагина в Pytest – это отличный способ повысить эффективность тестирования для Django разработок.

pytest-bdd

Одним из направлений применения Pytest может являться тестирование кода, сформированного на основе применения Gherkin – специализированного языка для так называемых разработок через поведение (BDD), являющихся ответвлением от, упоминаемых нами выше, разработок через тестирование (TDD). Основой BDD является создание понятных (легко-читаемых для неискушенных пользователей) сценариев ожидаемых действий пользователей, помогающих оценить целесообразность внедрения в ПО той или иной функциональности. Плагин же pytest-bdd позволяет интегрировать в Pytest вышеназванный язык Gherkin для дальнейшего написания на нем соответствующих функциональных тестов в рамках BDD.

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

Заключение

Мы с вами в этой статье рассмотрели лишь базовый набор возможностей для такого максимально универсального, надежного и весьма полезного при Python разработках, тестового фреймворка, как Pytest. Здесь, в сравнении со всем многообразием имеющихся у данного продукта инструментов, был рассмотрен лишь базовый набор функций в сфере фильтрации и оптимизации тестов. Также в этой статье были кратко затронуты лишь 4-ре из 1100, разработанных для Pytest плагинов.

Прочитав данную статью, вы, в частности, должны были понять, как использовать:

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

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

Ну, а пока, установите Pytest и попробуйте поработать с ним. Вам это должно точно понравится. Удачного тестирования!

Практический Python для начинающих
Практический Python для начинающих

Станьте junior Python программистом за 7 месяцев

 7 месяцев

Возможно будет интересно

🏆 Hello, world! Python
Новичок
🏆 Hello, world!

Мы вчера запустили новый www.pylot.me. Должны были в следующую среду, но запустили вчера.

2022-10-04
Как практиковаться в Python? Python
Новичок
Как практиковаться в Python?

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

2022-10-19
Условные конструкции и сопоставление структурных шаблонов Шпаргалки
Новичок
Условные конструкции и сопоставление структурных шаблонов

Шпаргалка по условным конструкциям и сопоставлению структурных шаблонов

2022-11-09