Покрываем REST-API сервис unit-тестами
В предыдущей статье "Как новичку начать писать юнит-тесты на Python" мы рассмотрели основные советы по написанию юнитов. Настало время конкретики и кода. Поехали!
Target project
Писать юниты на простенькие функции, проверяющие 1+1 == 2 скучно и мало продуктивно. Поэтому я написал шаблонный REST-API сервис, дабы тестировать максимально приближенный к продакшн-сервису код. Процесс написания самого сервиса планирую разобрать в будущем, а пока остановимся на основных компонентах.
Сам проект выложен на github. На том, как работать с git, пока останавливаться не буду. Если в коментах будет запрос на этот счет - напишу отдельную статью. В принципе, основные моменты описаны в Readme.md - там же приведены основные команды по установке зависимостей и поднятию проекта локально. Все зависимости, нужные для проекта расположены в файле pyproject.toml
Функционал сервиса реализовывает часть CRUD-методов (create, read, update, delete). В качестве сущностей (entity), которые мы будем создавать / получать - будут письма (email).
Сервис будет эмулировать процесс сохранения и получения данных email'а через http-протокол.
Вообще, сервис реализован в духе "чистой архитектуры" дяди Боба - поэтому по-ходу дела буду использовать понятия "сущность", "домен", "сервис", "интерфейс", "репозиторий" и тд.
Сущность - атомарная единица бизнес-логики. Обычно она представлена в виде дата-класса и располагается в пакете entities.
Алгоритм создания email
Пользователь через http отправляет POST запрос, вызывая PATH "/email/create" и передавая данные, соответствующие схеме CreateEmailRequest.
Роутер обрабатывает этот запрос, валидирует данные (через pydantic-модель CreateEmailRequest)
После успешной валидации создается экземпляр EmailBody, который передается в репозиторий EmailRepository.
Репозиторий является адаптером для работы с базой данных. Он помогает скрыть низкоуровневую логику сторонних библиотек, raw-SQL запросы от пользователя и предоставляет простой интерфейс.
Репозиторий сохраняет экземпляр EmailBody в БД, возвращая ID записи.
Роутер возвращает ID пользователю.
Далее по этому ID мы можем получить данные, используя другую ручку '/email/get/{email_id}'
Логика работы с входящими запросами расположена в пакете api.
Ручное тестирование приложения
Запустим локально сервис и отправим POST запрос на создание email.
Для этих целей я использую клиент postman
Как видно, ручка "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
Как видим, данные ответа не только совпадают с данными запроса, но и содержат дополнительные технические поля.
Проверка напрямую в базе
Подключимся к контейнеру, где поднят Postgres c базой rest_app_template и выполним SELECT
Unit-тесты роутеров
Очевидно, что тестировать в ручном режиме не всегда удобно, а зачастую (скажем при подходе CI/CD) - невозможно. Для автоматизации процесса используют связку юнит/функциональных/интеграционных тестов.
Начнем покрывать наше приложение юнитами. Для начала напишем самый простой тест на ручку "/health".
Кстати, если не хочется или нет возможности (например на сервере) использовать клиент postman, можно использовать утилиту curl. Она входит практически в любой *NIX дистрибутив и доступна в командной строке.
Юнит-тест будет выглядеть вот так:
По-фату, это обычная функция, которая должна начинаться с test__
Особое внимание стоит уделить аргументу client - это не простой аргумент, а фикстура-объект, который доступен между всеми тестами, использующие pytest. Подробнее про фикстуры можно почитать здесь, но если просто - фиктура определяется в специальном модуле conftest.py и выглядит она функция, обернутая специальным библиотечным декоратором.
После того, как мы определили фикстуру client в модуле conftest.py она будет доступна внутри своего пакета и во вложенных. Т.о. фикстуры, объявленные в корне пакета tests.unit.conftest.py будут видны во всех пакетах юнитов. Фикстуры, объяленные в unit.app.conftest.py будут видны только в app.
Тест test_health является примером положительного сценария, т.к. здесь результат является ожидаемым и код выполняет то, что от него требуется.
Очевидно, что в "нормальном" режиме отправка GET запроса на ручку "/health"должна возвращать успешных статус и строку 'Healthy'. Это является частью контракта приложения. Код теста это как раз и отражает.
Продолжает идею успешного сценария тест test_create_new_email
Здесь мы делаем тоже самое, что и в "ручном режиме" через postman. Основное отличие заключается в том, что в ручке создания email участвует БД, которую в юнитах мы не хотим поднимать. Для решения этой проблемы используется механизм мокирования (mocking) - перехват ожидаемого вызова метода и возврат результата, минуя явное соединение к БД.
Роль Mock-объекта (объекта-заглушки, stub) выполняют фикстуры email_repo и override_email_repo_dependency
Как можно видеть, email_repo возвращает экземпляр реального EmailRepository, который используется в "боевом" коде приложения. Отличие в том, что вместо настоящего db_pool мы подкладываем заглушку db_connection
В итоге, когда реальный класс репозитория вызывает методы db_pool, он делает это точно также, как и реальный код, но без вызова БД.
Для проверки ожидаемого поведения и того, какие методы и с какими аргументами были вызваны, используется фикстура when2 из библиотеки mockito
Здесь мы описываем кейс: "если у репозитория был вызван метод create_email с любыми аргументами - верни асинхронно значение STATIC_UUID".
Если же по каким-то причинам этот метод не будет вызван - возникнет исключение "unused stub".
Негативные сценарии
Помимо успешных сценариев, нужно убедиться, что приложение работает корректно (согласно протоколу) в случае невалидных входных данных, в случае ошибок соединения и т.д. Такие кейсы называют негативными - они описывают ответ системы, когда она по каким-либо причинам не выполнила целевую логику.
В нашем случае, напишем тест, который будет проверять ответы сервиса при запросах к ручке создания email с некорректными входными данными.
Пусть вас не пугает страшный декоратор @pytest.mark.parametrize. В нем лишь определен список значений аргументов, которые потом будут самим pytest переданы в тело теста и таким образом один тест-кейс будет запущен несколько раз на разных данных. Это сделано для того, чтобы не копипастить одинаковую логику, а переиспользовать. Принцип DRY в действии!
request_body - тело запроса, которое содержит невалидные данные, например тело с отсутствующим полем 'subject' (NOT_REQUIRED_ATTR_CREATE_EMAIL_REQUEST_BODY)
Напомню модель запроса:
Как видно, поле subject является обязательным - отсутствие в запросе должно возвращать статус 422 и ошибку валидации
exp_resp_status - ожидаемый статус ответа (в случае ошибки конечно)
descr - тело ответа, содержащее описание ошибки. Напомню, в случае успеха оно содержало UUID - id созданного email.
Само тело теста я думаю вы поймете без труда.
Как запускать тесты?
В корне проекта лежит файл Readme.md, в котором расписаны основные команды взаимодействия с проектом, в том числе и для запуста юнитов.
Для запуска в терминале
Для запуска в PyCharm
Это далеко не все тесты, которые есть в проекте. Есть еще тесты на репозиторий, с которыми можно ознакомиться самостоятельно.
Итоги
В этой статье мы познакомились с организацией проекта и написания тестов так, как принято в моем окружении.
Познакомились с понятием "чистой архитектуры" и конкретной реализацией REST-API приложения.
Разобрали как писать юнит-тесты для асинхронного http фреймворка FastAPI. Познакомились с понятием Mock-объекта и фикстур pytest.
Наконец, подняли в докере базу данных Postgres и отправили "реальные" запросы в сервис. Мы большие молодцы!
🖤 Подписывайтесь на мою телегу и вступайте в ВК паблик.
Больше кода 🐍 - меньше багов 🪲!