Інтеграційні тести на Python з використанням pytest та FastAPI. Частина друга
Вітаю друзі, настав час для другої частини. Першу частину можна почитати тут.
Цього разу хочу вам розповісти про переваги використання бібліотеки responses під час написання інтеграційних тестів. Переваги порівняно з mock.patch.
Код до другої частини можна знайти тут: застосунок, який вас цікавить, називається app_part_2
.
Переваги використання responses
Якщо коротко, ця бібліотека вберігає або навіть забороняє нашому застосунку робити запити на сторонні ресурси. Уявіть, що кожного разу, коли ви запускаєте тести, всі вони разом підуть на сторонні ресурси. Не дуже добре, правда? Також ми можемо підробляти (mock) відповіді від сторонніх ресурсів, додаючи такі атрибути як status_code
, json
, body
. Більше про це далі.
Додавання нової залежності
Для використання бібліотеки її треба встановити — нічого нового. Залежно від того, який менеджер модулів (package manager), команда буде виглядати по-різному. У нашому випадку це стандартний pip
:
pip install responses
Оновлення застосунку
У такому вигляді, як він є, наш застосунок не може бути протестований, тому я вніс деякі зміни і хочу пояснити, що саме я змінив.
- Отримавши нове замовлення, наш застосунок сформує і надішле запит до служби доставки.
- У разі успішного запиту буде повернуто відповідь під сервісу доставки або помилку, якщо щось пішло не так.
- На основному рівні застосунку ви можете знайти новий модуль —
deliveries
, де буде імплементованеapi
служби доставкиByCicle
(не шукайте в гуглі — воно вигадане). - В цьому
api
імплементовано кілька методів, аби емулювати роботу стороннього ресурсу.
Запуск вже наявних тестів
Для того, аби порівняти результати до і після, пропоную запустити тести без змін і подивитися, до чого це призведе.
test_get_orders
— ніяких змін, тест не використовує нічого окрім бази даних.
test_make_order
— під час спроби запуску тесту бачимо наступну помилку:
requests.exceptions.ConnectionError: HTTPSConnectionPool(host='bycicle service.com', port=443): Max retries exceeded with url: /register-order (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x7f700c803990>: Failed to resolve 'bycicle-service.com' ([Errno -2] Name or service not known)"))
Це означає, що в процесі виконання тесту ми спробували достукатися до стороннього сервісу. А оскільки його не існує, очевидно, отримали помилку.
Бібліотека responses
допомагає нам запобігти такому перебігу подій. Замість того, аби викликати сторонній ресурс, ми самі перехопимо власний запит і зможемо підмінити відповідь на ту, яка нам потрібна, аби перевірити всі можливі тестові випадки.
Створення нової fixture
Як і для будь-якої фікстури, найкраще місце для її оголошення — це наш conftest.py
файл
import responses @pytest.fixture(scope="session", autouse=True) def mocked_requests() -> responses.RequestsMock: """Prevents inner requests reach external resources.""" with responses.RequestsMock() as req: yield req
Поглянемо детальніше і почнемо з декораторів.
scope="session"
— ми хочемо мати доступ доступ для цієї фікстури в будь-якому тесті впродовж тестової сесії.
autouse=True
— ми не можемо дозволити собі, аби наші тести виходили за межі тесту і надсилали запити до наших партнерів, тому це налаштування допоможе нам запобігти цьому. Фікстура буде автоматично використана для всіх без винятку тестів, що перебувають на рівні tests/integration/conftest.py
.
За допомогою контекстного менеджера створюємо об’єкт RequestsMock
та очікуємо виклику цього об’єкту з тестів.
Знову спробуймо запустити тест test_make_order
і поглянемо, що змінилося. Як і очікувалося, картина зовсім інша:
requests.exceptions.ConnectionError: Connection refused by Responses - the call doesn't match any registered mock.
Це буквально означає, що тест намагався вийти за межі нашого застосунку, але був зупинений бібліотекою! Це однозначно успіх, навіть попри те, що тест впав.
Детальніше поглянемо на структуру помилки:
Перший зелений прямокутник — це, власне, і є сама помилка з детальним описом того, що пішло не так.
Другий — це url
, до якого хотів звернутися тест, також поряд ми бачимо тип запиту. Також ми бачимо поле Available matches
: яке наразі є порожнім, тому що ми не зареєстрували ще жодного запиту (далі я на прикладах поясню, що це таке).
Оновлення тестів
Пропоную перейти до власне оновлення тестів, бо, на мою думку, вступ щось затягнувся.
Ось як має виглядати оновлений тест:
def test_make_order( test_client: TestClient, database: Database, test_order: dict, mocked_requests: responses.RequestsMock, ) -> None: order_id = str(uuid1()) test_order["id"] = order_id mocked_requests.post( url=f"https://bycicle-service.com/register-order/{order_id}", status=HTTPStatus.OK, json={"status": "registered"}, ) response = test_client.post("/order", json=test_order) assert response.status_code == HTTPStatus.CREATED assert database.orders.find_one({"id": order_id})
- Як ми бачимо, налаштування
autouse=True
допомагають нам запобігти ще більшим проблемам. Та для того, аби зареєструвати запити, нам треба додати фікстуруmocked_requests
до аргументів нашого тесту. Для тих, хто любить залишатиtype hints
, — цеresponses.RequestsMock
. Раджу використовувати саме такий варіант, аби не забути, що воно і звідки. - Зареєструємо
url
як такий, що буде викликаний під час тесту. Іншими словами: ми додаємо наш конкретнийurl
доAvailable matches
: для того, аби бібліотека «знала» про нього. Також ми підміняємо статус відповіді наHTTPStatus.OK
та повертаємоjson
з інформацією про те, що замовлення було зареєстроване сервісом доставки. Тут як кажуть, it’s up to you — я особисто люблю використовувати змінні для посилань, але для чистоти тесту буду писати його повністю.
P.S. Важливий момент з реєстрацією запитів: за допомогою методу add_passthru
можна вказати один або декілька url
, які бібліотека не враховуватиме. Детальніше про це тут.
Запустимо тест ще раз і переконаємося, що він працює (сподіваюся):
Вітаю!
Перевірка негативного кейсу
Часто буває так, що в процесі роботи ми отримуємо не тільки правильно відповіді на наші запити, а й хто зна що — з усіма можливими та неможливими статус-кодами та вмістом.
Тому вважаю за потрібне також перевірити негативний тест кейс, у нас якраз такий є:
def register_order(self, order: Order) -> dict: url = f"{self._api_url}/register-order/{order.id}" response = self.perform_api_call(RequestType.POST, url, order.dict()) result = response.json() if not response.ok: raise DeliveryRegistrationError( order_id=order.id, message=result.get("error_message", "Delivery registration error"), ) return result
Бачимо перевірку об’єкту response
та його властивості ok
, що є обгорткою і буде рівним True
за значення статус-коду меншим за 400 HTTPStatus.BAD_REQUEST
. Документація тут. Негативний кейс — це якраз і означає, що в результаті запиту ми отримати щось таке, що поверне False
під час перевірки response.ok
.
Скопіювавши попередній тест, оновимо назву та дещо оновимо код. Особливу увагу хочу звернути на назву тесту, часто можна побачити суфікси _1
, _caseOne
абощо. Відверто кажучи, це не дуже допомагає зрозуміти, про що йдеться у тесті і для чого він написаний, тож намагайтеся писати тест так, щоб з його назви все було зрозуміло.
У нашому тесті ми хочемо перевірити сценарій, коли під час реєстрації замовлення ми отримали помилку від служби доставки (нам не важливо, яку, головне, щоб помилку), тож логічною назвою для тесту буде щось на кшталт:
def test_new_order_delivery_registration_error() -> None:
...
Може здаватися, що назва занадто довга — зате одразу все зрозуміло. Перейдемо до власне тесту:
def test_new_order_delivery_registration_error( test_client: TestClient, database: Database, test_order: dict, mocked_requests: responses.RequestsMock, ) -> None: order_id = str(uuid1()) test_order["id"] = order_id mocked_requests.post( url=f"https://bycicle-service.com/register-order/{order_id}", status=HTTPStatus.BAD_REQUEST, json={ "error_message": "ByCicle service is not available, too many orders", "status": "REJECTED", }, ) response = test_client.post("/order-new", json=test_order) assert response.status_code == HTTPStatus.BAD_REQUEST order = database.orders.find_one({"id": order_id}) assert order.get("status") == OrderStatus.FAILED
Як ми можемо бачити, тест майже ідентичний нашому першому тесту за винятком того, що тепер ми повертаємо інший статус-код та повідомлення про помилку. Також звертаю вашу увагу, що замість того, аби просто перевірити наявність замовлення, ми дістаємо його з бази даних та перевіряємо його статус.
Запускаємо тест і бачимо його успішне виконання
Таким чином обидва можливі варіанти розвитку подій покриті тестами. Це успіх!
Переваги порівняно зі звичайний mock.patch
Дехто міг би сказати, що використання цієї бібліотеки це є трохи over-engineering: мовляв, можна просто використати метод patch
зі стандартного модуля mock
і не витрачати час. Частково це правда. Якщо це unit-тест і ви перевіряєте конкретний кейс, можливо, і не варто, та я хочу показати одну суттєву перевагу використання responses
порівняно з mock.patch
.
Трохи змінимо наш код служби доставки та додамо один виклик всередині методу perform_api_call
. Скажімо наша служба доставки для авторизації вимагає токен, щоб отримати який нам треба постукати на ендпоінт /login
.
Оновлений код базового класу служби доставки буде виглядати так:
def perform_api_call( self, method: RequestType, url: str, payload: dict, **kwargs, ) -> requests.Response: headers = kwargs.get( "headers", {"Authorization": f"Bearer {self.get_auth_token()}"} ) response = self._session.request( method=method.name, url=url, json=payload, headers=headers, ) return response
Оновлення коду у ByCicle:
def get_auth_token(self) -> str: response = requests.get(self._api_url + "/login") if not response.ok: raise Exception("Failed to get auth token") return response.json().get("token")
Рішення проблеми з використанням mock.patch
Я буду використовувати підміну як декоратор, але є й інші способи — вибір за вами.
@mock.patch("deliveries.ByCicle.api.ByCicleAPI.get_auth_token", retun_value="bearer") def test_make_order( _mocked_token: mock.MagicMock, test_client: TestClient, database: Database, test_order: dict, mocked_requests: responses.RequestsMock, ) -> None: ...
По суті, використовуючи цей варіант. ми ігноруємо все, що перебуває всередині get_auth_token
методу та повертаємо результат одразу. Таке можна собі дозволити, якщо у вас високий відсоток покриття unit-тестами, якщо ж ні, тоді на production вас може чекати багато несподіванок.
Також звертаю вашу увагу, що тут я представив найпростіший випадок. Уявіть, що буде, якщо для того, аби отримати токен, вам треба спочатку дістати зі сховища (Vault) логін та пароль користувача, перевірити redis на наявність токену.
Рішення проблеми з використанням responses
Якщо ж ми продовжимо працювати в контексті responses
, нам просто треба додатково зареєструвати ще один виклик.
Виглядає це наступним так:
mocked_requests.get( url="https://bycicle-service.com/login", status=HTTPStatus.OK, json={"token": "bearer_token"}, )
У такому випадку ми ще на етапі тестування побачимо всі несподіванки, які б могли статися на production.
Висновок
Тож ще раз спробую пояснити, що зараз відбулося: за допомогою бібліотеки responses
ми покращили наші інтеграційні тести і досить суттєво. Тепер замість того, аби викликати сторонні ресурси (злити наших партнерів, якщо хочете) в процесі тестування ми можемо підмінити відповідь цих ресурсів і таким чином протестувати наш застосунок.
Також зможемо уникнути несподіваних помилок пов’язаний з підміною виклику методу mock.patch
підміняючи лише запити всередині цього методу.
Дякую за увагу.
Слава Збройним Силам! Слава Україні!
Бережіть себе!
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів