Эффективное тестирование с помощью Pytest

Kate

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

Из этого руководства вы узнаете:

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

Чтобы повторить примеры из этой статьи, вам нужно установить pytest. Для большинства пакетов Python это можно сделать в виртуальной среде из каталога PyPI с помощью стандартной системы управления пакетами pip:

$ python -m pip install pytest
После этого команда pytest появится в вашей установочной среде.

Чем полезен pytest?

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

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

Если вам еще не приходилось использовать pytest, вы получите большое удовольствие. Его философия и возможности делают процесс тестирования более продуктивным и приятным. С pytest обычные задачи требуют меньшего количества кода, а продвинутые решаются с помощью быстрых команд и плагинов. Более того, он изначально может запускать ваши существующие тесты без какого-либо переделывания, в том числе написанные в unittest.

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

Меньше шаблонного кода​

В большинстве функциональных тестов используется модель «подготовка — действие — проверка» (Arrange, Act, Assert):

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

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

# test_with_unittest.py

from unittest import TestCase

class TryTesting(TestCase):
def test_always_passes(self):
self.assertTrue(True)

def test_always_fails(self):
self.assertTrue(False)
Вы можете запустить эти тесты через командную строку, используя опцию discover в unittest:

$ python -m unittest discover
F.
============================================================
FAIL: test_always_fails (test_with_unittest.TryTesting)
------------------------------------------------------------
Traceback (most recent call last):
File "/.../test_with_unittest.py", line 9, in test_always_fails
self.assertTrue(False)
AssertionError: False is not True

------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)
Как и ожидается, один из них будет пройден, а другой нет. Таким образом, вы убедились, что unittest работает. Теперь давайте посмотрим, что для этого пришлось сделать:

  1. Импортировать класс TestCase из unittest.
  2. Создать TryTesting, подкласс для TestCase.
  3. Написать в TryTesting метод для каждого теста.
  4. Использовать один из методов self.assert* из unittest.TestCase для создания проверок.
Описанные действия требуют довольно большого объема кода, притом это минимальный объем для создания любого теста, а значит, вам придется писать одно и то же снова и снова. Pytest упрощает этот процесс, позволяя использовать ключевое слово assert напрямую:

# test_with_pytest.py

def test_always_passes():
assert True

def test_always_fails():
assert False
Вот и весь код. И не нужно возиться с импортами и классами. С ключевым словом assert вам не придется держать в голове разные методы self.assert* в unittest. Напишите выражение, которое должно оцениваться как True, и pytest протестирует его. Затем вы можете запустить его с помощью команды pytest:

$ pytest
================== test session starts =============================
platform darwin -- Python 3.7.3, pytest-5.3.0, py-1.8.0, pluggy-0.13.0
rootdir: /.../effective-python-testing-with-pytest
collected 2 items

test_with_pytest.py .F [100%]

======================== FAILURES ==================================
___________________ test_always_fails ______________________________

def test_always_fails():
> assert False
E assert False

test_with_pytest.py:5: AssertionError
============== 1 failed, 1 passed in 0.07s =========================
Результаты тестирования в pytest отображаются не так, как в unittest. Отчет показывает:

  1. Состояние системы, включая версии Python, pytest и всех установленных плагинов.
  2. Корневой каталог rootdir или каталог для конфигурирования и тестов.
  3. Число обнаруженных тестов.
Для отображения состояния каждого теста используется тот же синтаксис, что и в unittest:

  • Точка (.) означает, что тест пройден.
  • Буква F означает, что тест не пройден.
  • Буква E означает, что тест вызвал непредвиденное исключение.
Для проваленных тестов в отчете будет приведен подробный разбор ошибок. В примере выше тест не был пройден, потому что утверждение assert False всегда означает провал. Наконец, в отчете обозначается состояние для всего набора тестов.

Вот еще несколько коротких примеров проверок:

def test_uppercase():
assert "loud noises".upper() == "LOUD NOISES"

def test_reversed():
assert list(reversed([1, 2, 3, 4])) == [4, 3, 2, 1]

def test_some_primes():
assert 37 in {
num
for num in range(1, 50)
if num != 1 and not any([num % div == 0 for div in range(2, num)])
}
По сравнению с unittest, освоение pytest требует меньше времени, поскольку для большинства тестов не нужно учить новые конструкции. Кроме того, использование проверки, то есть assert, которая уже применялась в коде реализации, поможет сделать ваши тесты более понятными.

Управление состоянием и зависимостями​

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

Со временем такие неявные зависимости могут усложнять код, и его придется «распутывать», чтобы разобраться в сути тестов. А ведь одна из целей тестирования состоит в том, чтобы сделать код более понятным. Но если сами тесты трудны для понимания, с этим могут быть проблемы.

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

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

Фильтрация тестов​

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

  • Фильтрация по имени: вы можете заставить pytest запускать только те тесты, полное имя которых совпадает с определенным выражением. Для этого предусмотрен параметр -k.
  • Ограничение видимости каталога: pytest будет по умолчанию запускать только те тесты, которые находятся в текущем каталоге или ниже.
  • Категоризация тестов: pytest способен добавлять тесты в выбранную категорию или исключать их из нее. Это можно сделать с помощью параметра -m.
Категоризация тестов — мощный и точный инструмент. В pytest для тестов можно создавать маркеры (marks или markers, по-русски их также называют метками) — пользовательские ярлыки. Один тест может иметь несколько маркеров. Их можно использовать для отбора запускаемых тестов. Ниже мы приведем несколько примеров того, как работают маркеры в pytest и как использовать их в больших тестовых наборах.

Параметризация тестов​

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

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

Архитектура на основе плагинов​

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

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

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

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

Когда нужны фикстуры

Допустим, вы пишете функцию format_data_for_display() для обработки данных, полученных из конечной точки API. Данные — это список людей, у каждого из которых есть имя, фамилия и должность. Функция должна выводить несколько строк, содержащих given_name, family_name, двоеточие и title. Чтобы протестировать ее, вы можете написать следующий код:

def format_data_for_display(people):
... # Implement this!

def test_format_data_for_display():
people = [
{
"given_name": "Alfonsa",
"family_name": "Ruiz",
"title": "Senior Software Engineer",
},
{
"given_name": "Sayid",
"family_name": "Khan",
"title": "Project Manager",
},
]

assert format_data_for_display(people) == [
"Alfonsa Ruiz: Senior Software Engineer",
"Sayid Khan: Project Manager",
]
Теперь предположим, что вам нужно написать другую функцию, чтобы представить эти данные в виде значений, разделенных запятой, для импорта в Excel. Тест будет полностью аналогичным:

def format_data_for_excel(people):
... # Implement this!

def test_format_data_for_excel():
people = [
{
"given_name": "Alfonsa",
"family_name": "Ruiz",
"title": "Senior Software Engineer",
},
{
"given_name": "Sayid",
"family_name": "Khan",
"title": "Project Manager",
},
]

assert format_data_for_excel(people) == """given,family,title
Alfonsa,Ruiz,Senior Software Engineer
Sayid,Khan,Project Manager
"""
Именно в таких случаях, когда нужно написать несколько тестов с одинаковыми исходными данными, могут понадобиться фикстуры. Вы можете объединить повторяющиеся данные в одну функцию и добавить декоратор @pytest.fixture, чтобы обозначить ее как фикстуру:

import pytest

@pytest.fixture
def example_people_data():
return [
{
"given_name": "Alfonsa",
"family_name": "Ruiz",
"title": "Senior Software Engineer",
},
{
"given_name": "Sayid",
"family_name": "Khan",
"title": "Project Manager",
},
]
Теперь ее можно добавлять в тесты как аргумент. Его значение будет равно возвращаемому значению функции фикстуры:

def test_format_data_for_display(example_people_data):
assert format_data_for_display(example_people_data) == [
"Alfonsa Ruiz: Senior Software Engineer",
"Sayid Khan: Project Manager",
]

def test_format_data_for_excel(example_people_data):
assert format_data_for_excel(example_people_data) == """given,family,title
Alfonsa,Ruiz,Senior Software Engineer
Sayid,Khan,Project Manager
"""
Таким образом, каждый из тестов станет значительно короче, но при этом сохранит четкий путь к данным, от которых он зависит. Обязательно давайте своим фикстурам конкретные и понятные имена, чтобы по ним можно было потом определить, подойдут ли они в новых тестах.

Когда фикстуры не нужны​

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

Чтобы найти правильный баланс в использовании фикстур, нужны определенный опыт и практика.

Использование фикстур в больших масштабах​

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

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

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

Другой интересный способ использования фикстур — обеспечение защищенного доступа к ресурсам. Допустим, вы написали тестовый набор для проверки кода, который работает с вызовами API. Вы хотите убедиться, что тестовый набор не будет выполнять сетевые вызовы, даже если тест случайно использует код реальной сети. В pytest есть специальная фикстура monkeypatch для замены значений и изменения поведения:

# conftest.py

import pytest
import requests

@pytest.fixture(autouse=True)
def disable_network_calls(monkeypatch):
def stunted_get():
raise RuntimeError("Network access not allowed during testing!")
monkeypatch.setattr(requests, "get", lambda *args, **kwargs: stunted_get())
Разместив disable_network_calls() в conftest.py и добавив опцию autouse=True, вы можете отключить возможность совершения сетевых вызовов для всего тестового набора. Если какой-нибудь тест выполнит вызов кода requests.get(), это вызовет ошибку RuntimeError, обозначающую непредвиденный сетевой вызов.

Маркеры: категоризация тестов

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

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

Такая маркировка полезна для классификации по подсистемам или зависимостям. Например, если некоторым из ваших тестов нужен доступ к базе данных, вы можете создать для них маркер @pytest.mark.database_access.

Совет от специалиста: поскольку имена маркеров могут быть любыми, их легко забыть или допустить ошибку при вводе. Pytest предупредит вас об именах, которые ему не удалось распознать.

Флаг --strict-markers в команде pytest позволит вам быть уверенными в том, что все маркеры зарегистрированы в конфигурации pytest. Он просто не даст запустить тесты, пока вы этого не сделаете.

Подробнее о регистрации маркеров читайте в документации к pytest.

Вы все еще можете по умолчанию запустить все тесты одновременно с помощью команды pytest. Однако если вам нужны только те из них, которым требуется доступ к базе данных, используйте параметр pytest -m database_access. А чтобы запустить все тесты кроме этих, используйте pytest -m "not database_access". Вы даже можете добавить фикстуру autouse, чтобы ограничить доступ к базе данных для тестов с маркировкой database_access.

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

Добавление маркера django_db подталкивает вас к явному объявлению зависимостей в соответствии с философией pytest. Это также означает, что вы сможете намного быстрее запускать тесты, не связанные с базой данных, благодаря параметру pytest -m "not django_db", который предотвращает создание тестовой базы данных. Это существенно экономит время, особенно если вы стараетесь проводить тестирование как можно чаще.

В pytest есть несколько готовых маркеров:

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

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

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

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

def test_is_palindrome_empty_string():
assert is_palindrome("")

def test_is_palindrome_single_character():
assert is_palindrome("a")

def test_is_palindrome_mixed_casing():
assert is_palindrome("Bob")

def test_is_palindrome_with_spaces():
assert is_palindrome("Never odd or even")

def test_is_palindrome_with_punctuation():
assert is_palindrome("Do geese see God?")

def test_is_palindrome_not_palindrome():
assert not is_palindrome("abc")

def test_is_palindrome_not_quite():
assert not is_palindrome("abab")
Все они, за исключением последних двух, имеют одну и ту же форму:

def test_is_palindrome_<in some situation>():
assert is_palindrome("<some string>")
Вы можете использовать @pytest.mark.parametrize(), чтобы заполнить ее разными значениями и тем самым значительно сократить количество тестового кода:

@pytest.mark.parametrize("palindrome", [
"",
"a",
"Bob",
"Never odd or even",
"Do geese see God?",
])
def test_is_palindrome(palindrome):
assert is_palindrome(palindrome)

@pytest.mark.parametrize("non_palindrome", [
"abc",
"abab",
])
def test_is_palindrome_not_palindrome(non_palindrome):
assert not is_palindrome(non_palindrome)
Первый аргумент parametrize() — разделенное запятыми перечисление имен параметров. Второй — это список кортежей или одиночных значений для параметра. Следующим шагом вы можете объединить все тесты в один:

@pytest.mark.parametrize("maybe_palindrome, expected_result", [
("", True),
("a", True),
("Bob", True),
("Never odd or even", True),
("Do geese see God?", True),
("abc", False),
("abab", False),
])
def test_is_palindrome(maybe_palindrome, expected_result):
assert is_palindrome(maybe_palindrome) == expected_result
Заметим, что в данном случае, хотя параметризация позволила сократить количество тестового кода, она не очень помогла сделать его более понятным. Но если использовать параметризацию, чтобы отделять тестовые данные от тестового поведения, будет сразу понятно, что именно проверяет конкретный тест.

Отчеты о продолжительности тестирования: боремся с медленными тестами

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

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

Используйте опцию --durations для команды pytest, чтобы включить в результаты тестирования отчет о продолжительности. Опция --durations ожидает целое число n и сообщает об n самых медленных тестов. Эти данные будут выведены после результатов тестирования:

$ pytest --durations=3
3.03s call test_code.py::test_request_read_timeout
1.07s call test_code.py::test_request_connection_timeout
0.57s call test_code.py::test_database_read
======================== 7 passed in 10.06s ==============================
Все тесты, упомянутые в отчете о продолжительности, стоит рассматривать в качестве кандидатов на ускорение, поскольку их общее время тестирования превышает среднее.

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

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

Мы уже упомянули в этом руководстве несколько полезных плагинов для pytest. Ниже вы можете больше узнать о них и некоторых других.

pytest-randomly

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

Это отличный способ выявить тесты, которые должны запускаться в определенном порядке, так как они зависят от состояния других тестов. Если вы создали свой тестовый набор в pytest с нуля, их появление маловероятно. Гораздо чаще такое происходит при переносе наборов из других сред тестирования.

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

pytest-cov

Чтобы оценить, насколько хорошо ваши тесты покрывают код реализации, вы, вероятнее всего, используете пакет Coverage.py. Pytest-cov позволяет интегрировать этот пакет в среду тестирования и просматривать отчет о покрытии с помощью команды pytest --cov.

pytest-django

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

Узнать больше об использовании pytest в Django можно из статьи How to Provide Test Fixtures for Django Models in Pytest («Как создавать тестовые фикстуры для моделей Django в pytest»).

pytest-bdd

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

Описание других плагинов для pytest вы можете найти в обширном списке плагинов от сторонних разработчиков.

Заключение

Инструментарий pytest предлагает базовый набор функций для фильтрации и оптимизации тестов, а гибкая система плагинов делает его еще более полезным. Неважно, работаете ли вы с огромным тестовым набором, написанным в unittest, или создаете новый проект с нуля, pytest поможет вам.

В этом руководстве мы рассказали, как использовать:

  • фикстуры для управления зависимостями, состояниями и функционалом для многократного применения;
  • маркеры для категоризации тестов и ограничения доступа к внешним ресурсам;
  • параметризацию для сокращения объема одинакового кода в разных тестах;
  • отчеты о продолжительности тестирования для выявления самых медленных тестов;
  • плагины для интеграции с другими фреймворками и инструментами тестирования.
Установите pytest и попробуйте поработать с ним. Вам понравится. Удачного тестирования!

 
Сверху