Інтеграційні тести на Python з використанням pytest та FastAPI. Частина друга

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Вітаю друзі, настав час для другої частини. Першу частину можна почитати тут.

Цього разу хочу вам розповісти про переваги використання бібліотеки responses під час написання інтеграційних тестів. Переваги порівняно з mock.patch.

Код до другої частини можна знайти тут: застосунок, який вас цікавить, називається app_part_2.

Переваги використання responses

Якщо коротко, ця бібліотека вберігає або навіть забороняє нашому застосунку робити запити на сторонні ресурси. Уявіть, що кожного разу, коли ви запускаєте тести, всі вони разом підуть на сторонні ресурси. Не дуже добре, правда? Також ми можемо підробляти (mock) відповіді від сторонніх ресурсів, додаючи такі атрибути як status_code, json, body. Більше про це далі.

Додавання нової залежності

Для використання бібліотеки її треба встановити — нічого нового. Залежно від того, який менеджер модулів (package manager), команда буде виглядати по-різному. У нашому випадку це стандартний pip:

pip install responses 

Оновлення застосунку

У такому вигляді, як він є, наш застосунок не може бути протестований, тому я вніс деякі зміни і хочу пояснити, що саме я змінив.

  1. Отримавши нове замовлення, наш застосунок сформує і надішле запит до служби доставки.
  2. У разі успішного запиту буде повернуто відповідь під сервісу доставки або помилку, якщо щось пішло не так.
  3. На основному рівні застосунку ви можете знайти новий модуль — deliveries, де буде імплементоване api служби доставки ByCicle (не шукайте в гуглі — воно вигадане).
  4. В цьому 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}) 

  1. Як ми бачимо, налаштування autouse=True допомагають нам запобігти ще більшим проблемам. Та для того, аби зареєструвати запити, нам треба додати фікстуру mocked_requests до аргументів нашого тесту. Для тих, хто любить залишати type hints, — це responses.RequestsMock. Раджу використовувати саме такий варіант, аби не забути, що воно і звідки.
  2. Зареєструємо 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 підміняючи лише запити всередині цього методу.

Дякую за увагу.

Слава Збройним Силам! Слава Україні!

Бережіть себе!

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Не коректне порівняння mock.patch з responses:

Уявіть, що буде, якщо для того, аби отримати токен, вам треба спочатку дістати зі сховища (Vault) логін та пароль користувача, перевірити redis на наявність токену.

Мок може робити заглушки не тільки власних функцій, а й методів бібліотек (в данному випадку requests.get). Власне RequestsMock схоже це і робить, просто це сховано в бібліотеці. В цілому це питання смаку, але як на мене це трохи джаваскриптовий підхід — замість 4 стрічокок коду тягнути цілу бібліотеку.

mock_response = requests.Response() mock_response._content = b'{"token": "bearer_token"}' mock_response.status_code = 200 with mock.patch("deliveries.ByCicle.api.ByCicleAPI.get_auth_token.requests.get", retun_value=mock_response): ...

З рештою гарна стаття, щоб потикати FastAPI, для тих, хто ще цього не робив — саме те.

Все залежить від кількості тестів як на мене, писати 4 рядки для 10к тестів буде не так прикольно але так, Ваш варіант має право на життя.

Тільки якщо у Вас 10к тестів на 1 ендпоїнт. Зазвичай будуть різні запити і Вам потрібно буде так само писати 10к RequestsMock чи фікстур

Чому обрано саме FastAPI, які були інші варіанти, якщо були?

Питаю з цікавості, бо в моєму випадку раніше обрав для прототипу застосунку Flask... Відомо про те що він нативно неасинхронний. Ну бо я був дуже зелений, зараз трохи «пожовтішав». Міграція прототипу з Flask на FastAPI — ще те задоволення, багато компонентів доводиться дуже суттєво переписувати.

Ще цікавить, чи використовується якась ORM, і яка якщо так. В моєму випадку обрав SQLAlchemy ще на етапі роботи з Flask. Далі виявилося, що мігрувати лише з Flask на FastAPI для досягнення асинхронності недостатньо, якщо використовується SQLAlchemy, бо цей ORM нативно не підтримує асинхронність. Ось тепер думаю що краще обрати.

Ну й два слова про юніт-тести, бо теж використовую pytest. Самі тести доволі тривіальні, скажімо чи заповнені певні довідники в дб моделі, запускаються в Dockerfile перед supervisord, більше аби показати чи часом нема в самому коді застосунку критичних помилок які не дають ранити сам додаток.

Насправді цікаво почитати статті, хоча мій напрямок дещо інший. Ресурсів на тему FastAPI не так багато чомусь.

Успіхів надалі в цьому напрямку.

Дякую за питання!
Якогось тендеру фреймворків не було,

FastAPI

тому що простий, надійний, має багато чого з коробки. Flask теж крутий і не такий вже і «зелений». Використовуємо його на поточному проєкту. Конкретно тут ніякої ORM не було, все віддавалося на волю MongoDB.

SQLAlchemy

— на скільки я пам’ятаю там можна повертати результати за допомогою as_future() що по суті псевдо асинхронність.

Чому обрано саме FastAPI, які були інші варіанти, якщо були?

В мікросервісах FastAPI зараз by default. Часто ядро на Django, а все решта на орбіті на FastAPI.

Flask/Sanic своє віджили і крутяться там, де вже багато написано і міграція занадто дорога або в ній на поточний час відсутній раціональний сенс.

FastAPI/Pydantic/SQLAlchemy швидший за Flask/Marshmallow/SQLAlchemy. Pydantic можна використовувати з Flask, проте яке практичне застосування?

використовується SQLAlchemy, бо цей ORM нативно не підтримує асинхронність

Asyncpg, AsyncSession щось вам говорять? Асинхронне FastAPI з асинхронним SQLAlchemy працює чудово.
docs.sqlalchemy.org/...​asyncio.html#synopsis-orm

Самі тести доволі тривіальні, скажімо чи заповнені певні довідники в дб моделі, запускаються в Dockerfile перед supervisord, більше аби показати чи часом нема в самому коді застосунку критичних помилок які не дають ранити сам додаток.

Налаштуйте нормально CI/CD, котре запускає тести після commit push у вашу бранчу і не дає змерджити Pull Request в main при наявних помилках.

Ресурсів на тему FastAPI не так багато чомусь.

Ви що українською гуглите? Ресурсів та туторіалів за останні 4 роки просто тьма, а документація самого FastAPI доволі зрозуміла і покриває більшість питань.

Підписатись на коментарі