Покрываем REST-API сервис unit-тестами

Покрываем REST-API сервис unit-тестами

В предыдущей статье "Как новичку начать писать юнит-тесты на Python" мы рассмотрели основные советы по написанию юнитов. Настало время конкретики и кода. Поехали!

Target project

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

Сам проект выложен на github. На том, как работать с git, пока останавливаться не буду. Если в коментах будет запрос на этот счет - напишу отдельную статью. В принципе, основные моменты описаны в Readme.md - там же приведены основные команды по установке зависимостей и поднятию проекта локально. Все зависимости, нужные для проекта расположены в файле pyproject.toml

Проект написан на FastAPI - асинхронном ASGI Python фреймворке. На данный момент он является де-факто стандартом для написания асинхронных rest-сервисов из-за быстроты и встроенной валидации. В качестве валидатора используем библиотеку pydantic

Функционал сервиса реализовывает часть CRUD-методов (create, read, update, delete). В качестве сущностей (entity), которые мы будем создавать / получать - будут письма (email).

Сервис будет эмулировать процесс сохранения и получения данных email'а через http-протокол.

Вообще, сервис реализован в духе "чистой архитектуры" дяди Боба - поэтому по-ходу дела буду использовать понятия "сущность", "домен", "сервис", "интерфейс", "репозиторий" и тд.

луковица "чистой архитектуры"
луковица "чистой архитектуры"

Сущность - атомарная единица бизнес-логики. Обычно она представлена в виде дата-класса и располагается в пакете entities.

Модель данных email
Модель данных email

Алгоритм создания email

Пользователь через http отправляет POST запрос, вызывая PATH "/email/create" и передавая данные, соответствующие схеме CreateEmailRequest.

Роутер обрабатывает этот запрос, валидирует данные (через pydantic-модель CreateEmailRequest)

После успешной валидации создается экземпляр EmailBody, который передается в репозиторий EmailRepository.

Репозиторий является адаптером для работы с базой данных. Он помогает скрыть низкоуровневую логику сторонних библиотек, raw-SQL запросы от пользователя и предоставляет простой интерфейс.

Репозиторий сохраняет экземпляр EmailBody в БД, возвращая ID записи.

Роутер возвращает ID пользователю.

Далее по этому ID мы можем получить данные, используя другую ручку '/email/get/{email_id}'

Логика работы с входящими запросами расположена в пакете api.

Покрываем REST-API сервис unit-тестами

Ручное тестирование приложения

Запустим локально сервис и отправим POST запрос на создание email.

Для этих целей я использую клиент postman

Отправка запроса на создание email
Отправка запроса на создание email

Как видно, ручка "email/create" вернула нам status=200 и в теле ответа UUID="fd0646bf-6bf1-490a-9348-d2af326fea46" - это ID только что созданного email'а.

Проверим, что в БД все записалось правильно. Сделать это можно двумя способами - подключиться напрямую к БД rest_app_template или отправить GET-запрос на другую ручку "email/get/{email_id}"

Проверка через роут

Отправим GET-запрос на адрес

email/get/fd0646bf-6bf1-490a-9348-d2af326fea46

Покрываем REST-API сервис unit-тестами

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

Проверка напрямую в базе

Подключимся к контейнеру, где поднят Postgres c базой rest_app_template и выполним SELECT

Покрываем REST-API сервис unit-тестами

Unit-тесты роутеров

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

Начнем покрывать наше приложение юнитами. Для начала напишем самый простой тест на ручку "/health".

Кстати, если не хочется или нет возможности (например на сервере) использовать клиент postman, можно использовать утилиту curl. Она входит практически в любой *NIX дистрибутив и доступна в командной строке.

% curl -X GET http://127.0.0.1:8080/health Healthy

Юнит-тест будет выглядеть вот так:

def test_health(client): resp = client.get(HEALTH_PATH) assert resp.status_code == 200 assert resp.text == 'Healthy'

По-фату, это обычная функция, которая должна начинаться с test__

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

Фикстура http-client, которую используют для отправки тестовых запросов к сервису
Фикстура http-client, которую используют для отправки тестовых запросов к сервису

После того, как мы определили фикстуру client в модуле conftest.py она будет доступна внутри своего пакета и во вложенных. Т.о. фикстуры, объявленные в корне пакета tests.unit.conftest.py будут видны во всех пакетах юнитов. Фикстуры, объяленные в unit.app.conftest.py будут видны только в app.

Область видимости фикстур
Область видимости фикстур

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

Очевидно, что в "нормальном" режиме отправка GET запроса на ручку "/health"должна возвращать успешных статус и строку 'Healthy'. Это является частью контракта приложения. Код теста это как раз и отражает.

Продолжает идею успешного сценария тест test_create_new_email

def test_create_new_email(client, override_email_repo_dependency): resp = client.post( CREATE_EMAIL, json=CREATE_EMAIL_REQUEST_BODY, ) assert resp.status_code == 200 assert resp.json() == str(STATIC_UUID)
Проверяем, что id в ответе соответствует ожидаемому STATIC_UUID
Проверяем, что id в ответе соответствует ожидаемому STATIC_UUID

Здесь мы делаем тоже самое, что и в "ручном режиме" через postman. Основное отличие заключается в том, что в ручке создания email участвует БД, которую в юнитах мы не хотим поднимать. Для решения этой проблемы используется механизм мокирования (mocking) - перехват ожидаемого вызова метода и возврат результата, минуя явное соединение к БД.

Роль Mock-объекта (объекта-заглушки, stub) выполняют фикстуры email_repo и override_email_repo_dependency

@pytest.fixture def email_repo(db_connection, settings): yield EmailRepository(db_pool=db_connection) @pytest.fixture def override_email_repo_dependency(when2, app, email_repo, async_result): async def _foo(): when2(email_repo.create_email, ...).thenReturn(async_result(STATIC_UUID)) return email_repo app.dependency_overrides[get_email_repo] = _foo yield

Как можно видеть, email_repo возвращает экземпляр реального EmailRepository, который используется в "боевом" коде приложения. Отличие в том, что вместо настоящего db_pool мы подкладываем заглушку db_connection

@pytest.fixture def db_connection(): class Pool: @asynccontextmanager async def acquire(self): yield self @asynccontextmanager async def transaction(self): yield self async def executemany(self, *args, **kwargs): pass async def set_type_codec(self, *args, **kwargs): pass async def execute(self, *args, **kwargs): pass async def fetch(self, *args, **kwargs): pass async def fetchval(self, *args, **kwargs): pass async def fetchrow(self, *args, **kwargs): pass yield Pool()

В итоге, когда реальный класс репозитория вызывает методы db_pool, он делает это точно также, как и реальный код, но без вызова БД.

Для проверки ожидаемого поведения и того, какие методы и с какими аргументами были вызваны, используется фикстура when2 из библиотеки mockito

when2(email_repo.create_email, ...).thenReturn(async_result(STATIC_UUID))

Здесь мы описываем кейс: "если у репозитория был вызван метод create_email с любыми аргументами - верни асинхронно значение STATIC_UUID".

Если же по каким-то причинам этот метод не будет вызван - возникнет исключение "unused stub".

Негативные сценарии

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

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

@pytest.mark.parametrize( 'request_body, exp_resp_status, descr', [ ( NOT_REQUIRED_ATTR_CREATE_EMAIL_REQUEST_BODY, 422, [{'loc': ['body', 'subject'], 'msg': 'field required', 'type': 'value_error.missing'}], ), ( INVALID_ATTR_CREATE_EMAIL_REQUEST_BODY, 422, [{'loc': ['body', 'subject'], 'msg': 'str type expected', 'type': 'type_error.str'}], ), ], ) def test_create_new_email_invalid_request(client, override_email_repo_empty_mock, request_body, exp_resp_status, descr): resp = client.post( CREATE_EMAIL, json=request_body, ) assert resp.status_code == exp_resp_status assert resp.json()['detail'] == descr

Пусть вас не пугает страшный декоратор @pytest.mark.parametrize. В нем лишь определен список значений аргументов, которые потом будут самим pytest переданы в тело теста и таким образом один тест-кейс будет запущен несколько раз на разных данных. Это сделано для того, чтобы не копипастить одинаковую логику, а переиспользовать. Принцип DRY в действии!

request_body - тело запроса, которое содержит невалидные данные, например тело с отсутствующим полем 'subject' (NOT_REQUIRED_ATTR_CREATE_EMAIL_REQUEST_BODY)

Напомню модель запроса:

class CreateEmailRequest(BaseModel): smtp_address_from: str smtp_address_to: str subject: str body: Optional[str]

Как видно, поле subject является обязательным - отсутствие в запросе должно возвращать статус 422 и ошибку валидации

exp_resp_status - ожидаемый статус ответа (в случае ошибки конечно)

descr - тело ответа, содержащее описание ошибки. Напомню, в случае успеха оно содержало UUID - id созданного email.

Само тело теста я думаю вы поймете без труда.

Как запускать тесты?

В корне проекта лежит файл Readme.md, в котором расписаны основные команды взаимодействия с проектом, в том числе и для запуста юнитов.

Для запуска в терминале

% pytest -v tests/unit OR % poetry run pytest -v tests/unit
Вывод результатов выполнения тестов в терминале
Вывод результатов выполнения тестов в терминале

Для запуска в PyCharm

Выбираем pytest в секции Python tests и выставляем путь до юнитов
Выбираем pytest в секции Python tests и выставляем путь до юнитов
Нажимаем кнопочку
Нажимаем кнопочку
Наслаждаемся успехом ;)
Наслаждаемся успехом ;)

Это далеко не все тесты, которые есть в проекте. Есть еще тесты на репозиторий, с которыми можно ознакомиться самостоятельно.

Итоги

В этой статье мы познакомились с организацией проекта и написания тестов так, как принято в моем окружении.

Познакомились с понятием "чистой архитектуры" и конкретной реализацией REST-API приложения.

Разобрали как писать юнит-тесты для асинхронного http фреймворка FastAPI. Познакомились с понятием Mock-объекта и фикстур pytest.

Наконец, подняли в докере базу данных Postgres и отправили "реальные" запросы в сервис. Мы большие молодцы!

🖤 Подписывайтесь на мою телегу и вступайте в ВК паблик.

Больше кода 🐍 - меньше багов 🪲!

11
Начать дискуссию