Перейти к содержанию

Асинхронное тестирование

Вы уже видели как тестировать FastAPI приложение, используя имеющийся класс TestClient. К этому моменту вы видели только как писать тесты в синхронном стиле без использования async функций.

Возможность использования асинхронных функций в ваших тестах может быть полезнa, когда, например, вы асинхронно обращаетесь к вашей базе данных. Представьте, что вы хотите отправить запросы в ваше FastAPI приложение, а затем при помощи асинхронной библиотеки для работы с базой данных удостовериться, что ваш бекэнд корректно записал данные в базу данных.

Давайте рассмотрим, как мы можем это реализовать.

pytest.mark.anyio

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

HTTPX

Даже если FastAPI приложение использует обычные функции def вместо async def, это все равно async приложение 'под капотом'.

Чтобы работать с асинхронным FastAPI приложением в ваших обычных тестовых функциях def, используя стандартный pytest, TestClient внутри себя делает некоторую магию. Но эта магия перестает работать, когда мы используем его внутри асинхронных функций. Запуская наши тесты асинхронно, мы больше не можем использовать TestClient внутри наших тестовых функций.

TestClient основан на HTTPX, и, к счастью, мы можем использовать его (HTTPX) напрямую для тестирования API.

Пример

В качестве простого примера, давайте рассмотрим файловую структуру, схожую с описанной в Большие приложения и Тестирование:

.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py

Файл main.py:

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Tomato"}

Файл test_main.py содержит тесты для main.py, теперь он может выглядеть так:

import pytest
from httpx import ASGITransport, AsyncClient

from .main import app


@pytest.mark.anyio
async def test_root():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        response = await ac.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Tomato"}

Запуск тестов

Вы можете запустить свои тесты как обычно:

$ pytest

---> 100%

Подробнее

Маркер @pytest.mark.anyio говорит pytest, что тестовая функция должна быть вызвана асинхронно:

import pytest
from httpx import ASGITransport, AsyncClient

from .main import app


@pytest.mark.anyio
async def test_root():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        response = await ac.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Tomato"}

Подсказка

Обратите внимание, что тестовая функция теперь async def вместо простого def, как это было при использовании TestClient.

Затем мы можем создать AsyncClient со ссылкой на приложение и посылать асинхронные запросы, используя await.

import pytest
from httpx import ASGITransport, AsyncClient

from .main import app


@pytest.mark.anyio
async def test_root():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        response = await ac.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Tomato"}

Это эквивалентно следующему:

response = client.get('/')

...которое мы использовали для отправки наших запросов с TestClient.

Подсказка

Обратите внимание, что мы используем async/await с AsyncClient - запрос асинхронный.

Внимание

Если ваше приложение полагается на lifespan события, то AsyncClient не запустит эти события. Чтобы обеспечить их срабатывание используйте LifespanManager из florimondmanca/asgi-lifespan.

Вызов других асинхронных функций

Теперь тестовая функция стала асинхронной, поэтому внутри нее вы можете вызывать также и другие async функции, не связанные с отправлением запросов в ваше FastAPI приложение. Как если бы вы вызывали их в любом другом месте вашего кода.

Подсказка

Если вы столкнулись с RuntimeError: Task attached to a different loop при вызове асинхронных функций в ваших тестах (например, при использовании MongoDB's MotorClient), то не забывайте инициализировать объекты, которым нужен цикл событий (event loop), только внутри асинхронных функций, например, в '@app.on_event("startup") callback.