Расширенные методы насмешек, представленные на примере

Юнит-тестирование — это искусство. Хотя вопрос о том, что тестировать, имеет решающее значение (вы не можете протестировать все), в этом посте мы более подробно рассмотрим некоторые продвинутые методы тестирования и имитации в Python. Хотя некоторые из них уже были представлены в предыдущих публикациях (Часть 1, Часть 2, Часть 3), этот пост отличается тем, что показывает их взаимодействие на реальном примере, а также выходит за рамки предыдущего. публикует и добавляет больше информации.

Среди прочего мы обсудим:

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

Резюме необходимых тем

Прежде чем погрузиться в фактическое содержание этого поста, мы сначала кратко расскажем о том, как использовать pytest. Затем мы делаем то же самое для asyncio, так как наш пример использует это. Если вы уже знакомы с этими темами, смело переходите к следующему разделу.

питест

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

Для начала давайте рассмотрим один пример из предыдущего поста:

import pytest

class MySummationClass:
    def sum(self, x, y):
        return x + y

@pytest.fixture
def my_summation_class():
    return MySummationClass()

def test_sum(my_summation_class):
    assert my_summation_class.sum(2, 3) == 5

Мы можем запустить этот тест через python -m pytest test_filename.py. При этом pytest обнаруживает все тесты в этом файле в соответствии с некоторыми условиями (например, все функции с именем test_…) и выполняет их. В нашем случае мы определили фикстуру, возвращающую экземпляр MySummationClass. Светильники можно использовать, например, для избегать повторной инициализации и модализировать код. Затем мы вызываем метод sum() этого экземпляра и проверяем, что результат равен ожидаемому, через assert.

Насмешка

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

Рассмотрим пример из предыдущего поста:

import time
from unittest.mock import Mock, patch


def expensive_function() -> int:
    time.sleep(10)
    return 42


def function_to_be_tested() -> int:
    expensive_operation_result = expensive_function()
    return expensive_operation_result * 2


@patch("sample_file_name.expensive_function")
def test_function_to_be_tested(mock_expensive_function: Mock) -> None:
    mock_expensive_function.return_value = 42
    assert function_to_be_tested() == 84

Мы используем декоратор, чтобы залатать expensive_function на mock_expensive_function, таким образом заменив долгое время работы исходной функции на функцию с аналогичными свойствами, выбранную нами.

асинхронный

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

Давайте повторно используем мотивирующий пример из предыдущего поста:

import asyncio


async def sleepy_function():
    print("Before sleeping.")
    await asyncio.sleep(1)
    print("After sleeping.")


async def main():
    await asyncio.gather(*[sleepy_function() for _ in range(3)])


asyncio.run(main())

Если бы мы запускали sleepy_function обычным образом три раза подряд, это заняло бы 3 секунды. Однако с помощью asyncio эта программа завершается за 1 с: collect планирует выполнение трех вызовов функций, а внутри sleepy_function ключевое слово await возвращает управление основному циклу, у которого есть время для выполнения другого кода (здесь: другие экземпляры sleepy_function) во время сна. за 1с.

Настройка проблемы

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

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

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

Проект состоит из следующих файлов, которые вы также можете найти на github:

pyproject.toml

[tool.poetry]
name = "Pytest Example"
version = "0.1.0"
description = "A somewhat larger pytest example"
authors = ["hermanmichaels <[email protected]>"]

[tool.poetry.dependencies]
python = "3.10"
mypy = "0.910"
pytest = "7.1.2"
pytest-asyncio = "0.21.0"
black = "22.3.0"
flake8 = "4.0.1"
isort = "^5.10.1"

message_sending.py

import asyncio

from message_utils import Message, generate_message_client


def generate_messages() -> list[Message]:
    return [Message("Message 1"), Message("Message 2")]


async def send_messages() -> None:
    message_client = generate_message_client()
    messages = generate_messages()
    await asyncio.gather(*[message_client.send(message) for message in messages])


def main() -> None:
    asyncio.run(send_messages())


if __name__ == "__main__":
    main()

message_utils.py

from dataclasses import dataclass


@dataclass
class Message:
    body: str


class MessageClient:
    async def send(self, message: Message) -> None:
        print(f"Sending message: {message}")


def generate_message_client() -> MessageClient:
    return MessageClient()

Мы можем запустить эту программу через python message_sending.py, которая, как указано выше, сначала создает экземпляр MessageClient, затем генерирует список фиктивных сообщений через generate_messages и в конечном итоге отправляет их с помощью asyncio. На последнем шаге мы создаем задачи message_client.send(message) для каждого сообщения, а затем запускаем их асинхронно через gather.

Тестирование

С этим, давайте перейдем к тестированию. Здесь наша цель — создать несколько сценариев и убедиться, что правильные сообщения отправляются через клиент сообщений. Естественно, наша простая демонстрационная настройка слишком упрощена, чтобы охватить это, но представьте следующее: в реальности вы используете клиент для отправки сообщений клиентам. В зависимости от определенных событий (например, покупка/продажа товара) будут создаваться разные сообщения. Таким образом, вы хотите имитировать эти различные сценарии (например, сымитировать кого-то, покупающего продукт) и проверить, что правильные электронные письма генерируются и отправляются.

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

Тестовая функция была вызвана один раз

Для первой попытки давайте изменим generate_messages, чтобы создать только одно сообщение. Затем мы ожидаем, что функция send() будет вызвана один раз, что мы и проверим здесь.

Вот как выглядит соответствующий тест:

from unittest.mock import AsyncMock, Mock, call, patch

import pytest as pytest
from message_sending import send_messages
from message_utils import Message


@pytest.fixture
def message_client_mock():
    message_client_mock = Mock()
    message_client_mock_send = AsyncMock()
    message_client_mock.send = message_client_mock_send
    return message_client_mock


@pytest.mark.asyncio
@patch("message_sending.generate_message_client")
async def test_send_messages(
    generate_message_client: Mock, message_client_mock: Mock
):
    generate_message_client.return_value = message_client_mock

    await send_messages()

    message_client_mock.send.assert_called_once()

Давайте разберем это подробнее: test_send_messages — наша основная тестовая функция. Мы исправили функцию generate_message_client, чтобы не использовать настоящий (почтовый) клиент, возвращенный в оригинальной функции. Обратите внимание на где патчить: generate_message_client определяется в message_utils, но поскольку он импортируется через from message_utils import generate_message_client, мы должны указать message_sending в качестве цели патча.

Мы еще не закончили из-за asyncio. Если бы мы продолжили, не добавляя больше деталей к фиктивному клиенту сообщения, мы получили бы ошибку, подобную следующей:

TypeError: Требуется asyncio.Future, сопрограмма или ожидаемый объект… ValueError(‘Future принадлежит циклу, отличному от ‘ ‘того, который указан в качестве аргумента цикла’).

Причина этого в том, что в message_sending мы вызываем asyncio.gather на message_client.send. Однако на данный момент фиктивный клиент сообщений и, следовательно, его send сообщение являются просто Mock объектами, которые не могут быть запланированы асинхронно. Чтобы обойти это, мы ввели фикстуру message_client_mock. Здесь мы определяем объект Mock с именем message_client_mock, а затем определяем его метод send как объект AsyncMock. Затем мы назначаем это как return_value функции generate_message_client.

Обратите внимание, что pytest изначально не поддерживает asyncio, но ему нужен пакет pytest-asyncio, который мы установили в файле pyproject.toml.

Тестовая функция была вызвана один раз с определенным аргументом

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

Для этого сначала перегружаем оператор равенства для Message:

def __eq__(self, other: object) -> bool:
    if not isinstance(other, Message):
        return NotImplemented
    return self.body == other.body

Затем, в конце теста, мы используем следующее ожидание:

message_client_mock.send.assert_called_once_with(Message("Message 1"))

Частично совпадающие аргументы

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

Для этого мы реализуем новый прокси-класс, который реализует оператор __eq__ w.r.t. Message. Здесь мы просто подклассируем строку и проверяем, содержится ли она в message.body:

class MessageMatcher(str):
    def __eq__(self, other: object):
        if not isinstance(other, Message):
            return NotImplemented
        return self in other.body

Затем мы можем утверждать, что отправленное сообщение, например. содержит «1»:

message_client_mock.send.assert_called_once_with(MessageMatcher("1"))

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

Естественно, возможность проверить, была ли функция вызвана только один раз, не очень полезна. Если мы хотим проверить, что функция была вызвана N раз с разными аргументами, мы можем использовать assert_has_calls. Здесь ожидается список типа call, где каждый элемент описывает один ожидаемый вызов:

message_client_mock.send.assert_has_calls(
    [call(Message("Message 1")), call(MessageMatcher("2"))]
)

Заключение

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

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

Надеюсь, вам понравилось читать этот урок — спасибо за внимание!