Привет, Хабр!

Принято считать, что тесты в FastAPI — самая спокойная часть проекта для Python‑разработчика.

Документация показывает три строки:

  • импортировали TestClient;

  • отправили запрос;

  • сравнили статус.

Со стороны это выглядит как маленький HTTP‑клиент, за которым стоит ваше приложение, примерно как curl против локального сервера.

Сервера там нет. TestClient не открывает сокет и не слушает порт: он берёт ASGI‑приложение и вызывает его как функцию, app(scope, receive, send), в фоновом потоке со своим событийным циклом.

Ответ собирается из сообщений, которые приложение отправило в send, и упаковывается в обычный объект ответа httpx. В большинстве тестов разницы не видно, и редкие расхождения из‑за этого выглядят как мистика.

Самое неприятное из них устроено так: первый запрос в тесте проходит, второй падает с ошибкой asyncpg про «другую операцию», которой нигде нет. Под uvicorn тот же код отвечает без ошибок сколько угодно раз.

Первый тест: статус, JSON и 422

Возьмём маленький сервис заказов. Одна ручка, модель входных данных с проверкой и зависимость, которая достаёт пользователя из заголовка:

from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()

class OrderIn(BaseModel):
    sku: str
    qty: int = Field(gt=0)

def get_current_user(authorization: Annotated[str | None, Header()] = None):
    if authorization != "Bearer real-token":
        raise HTTPException(401, "нужен токен")
    return {"name": "alice"}

@app.post("/orders", status_code=201)
def create_order(order: OrderIn, user: Annotated[dict, Depends(get_current_user)]):
    return {"sku": order.sku, "qty": order.qty, "owner": user["name"]}

Тесты на неё пишутся почти без подготовки.

Клиент создаётся один раз на модуль, запрос отправляется методом с именем HTTP‑глагола, а у ответа есть status_code и json(), как у любого ответа httpx:

from fastapi.testclient import TestClient
from app import app

client = TestClient(app)
AUTH = {"Authorization": "Bearer real-token"}

def test_create_order():
    r = client.post("/orders", json={"sku": "A-1", "qty": 2}, headers=AUTH)
    assert r.status_code == 201
    assert r.json() == {"sku": "A-1", "qty": 2, "owner": "alice"}

def test_qty_must_be_positive():
    r = client.post("/orders", json={"sku": "A-1", "qty": 0}, headers=AUTH)
    assert r.status_code == 422
    err = r.json()["detail"][0]
    assert err["loc"] == ["body", "qty"]
    assert err["type"] == "greater_than"

Для некорректных данных мало проверить код 422. Нужно ещё посмотреть, на какое поле пожаловался FastAPI. В detail лежит список ошибок, и у каждой есть loc, type и msg.

Первые два поля написаны для машины, третье — для человека, и сравнивать в тесте удобнее машинные.

Если из второго теста убрать заголовок, ответ будет 401, а не 422. Зависимость с пользователем падает раньше, чем FastAPI успевает разобраться с телом, и тест на валидацию молча превращается в тест на авторизацию.

В нашем случае он упадёт, и это сразу заметно.

В тесте, который проверяет только status_code != 201, такая подмена пройдёт втихую.

Сам fastapi.testclient.TestClient — это одна строка реэкспорта из Starlette. Starlette вышел в версии 1.0 22 марта 2 026 года.

С версии 1.2 его тестовый клиент умеет работать с httpx2. Это форк httpx 0.28.1 от Pydantic с тем же API и другим именем модуля. С обычным httpx Starlette 1.7 продолжает работать, но при импорте пишет предупреждение об устаревании.

Одно приложение, четыре способа его позвать

Чтобы увидеть, где тестовый клиент расходится с сервером, я собрал приложение с тремя проверками:

  • Lifespan при старте ставит флаг в app.state.

  • Ручка /probe возвращает этот флаг и идентификатор текущего событийного цикла, её вызываем дважды и сравниваем циклы.

  • Ручка /boom бросает KeyError, а обработчик для Exception превращает его в JSON с кодом 500.

  • И есть POST /items/ со слэшем на конце, в который мы стучимся без слэша.

Это приложение я прогнал через четыре клиента: TestClient, как в примере выше, TestClient внутри with, httpx.AsyncClient с ASGITransport, как советует документация для асинхронных тестов, и настоящий uvicorn, к которому ходит обычный httpx по сети.

Версии: FastAPI 0.143.0, Starlette 1.7.0, uvicorn 0.54.0.

TestClient                lifespan=False один цикл=False /boom -> KeyError POST /items -> 201 после 307
with TestClient           lifespan=True  один цикл=True  /boom -> KeyError POST /items -> 201 после 307
AsyncClient+ASGITransport lifespan=False один цикл=True  /boom -> KeyError POST /items -> 307
uvicorn + httpx           lifespan=True  один цикл=True  /boom -> 500      POST /items -> 307

Последняя строка — это то, что увидит настоящий клиент.

Остальные три расходятся с ней каждая в своём месте, и ни одно расхождение нельзя назвать ошибкой: всё это где‑то описано, просто в разных местах документации и в разных библиотеках.

Каждый запрос в своём событийном цикле

Без with тестовый клиент на каждый запрос заводит новый «портал» anyio: поднимает поток, запускает в нём событийный цикл, выполняет приложение и закрывает цикл вместе с потоком. Следующий запрос получает новый цикл.

Внутри with портал один на весь блок, и при входе в блок заодно выполняется lifespan.

  • Для синхронного кода это ничего не меняет.

  • Для асинхронного меняет всё, что живёт дольше одного запроса. Условно — пул соединений, который создаётся лениво при первом обращении:

pool = None

async def get_pool():
    global pool
    if pool is None:
        pool = await asyncpg.create_pool(DSN, min_size=1, max_size=2)
    return pool

@app.get("/now")
async def now():
    p = await get_pool()
    return {"now": str(await p.fetchval("select now()"))}

Под uvicorn событийный цикл один на весь процесс. Пул создаётся в нём один раз и живёт до остановки. В тесте без with первый запрос получает 200, а второй падает вот так:

asyncpg.exceptions.InterfaceError: cannot perform operation: another operation is in progress

Никакой другой операции нет.

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

asyncpg видит соединение в странном состоянии и сообщает об этом единственными словами, которые у него для такого случая есть.

Сообщение описывает состояние соединения. О причине в нём ни слова.

Похожая история случается, когда пул создают в асинхронной фикстуре pytest‑asyncio и подкладывают приложению. Тогда не помогает даже with: у фикстуры свой цикл, у портала свой, и запрос падает с got Future attached to a different loop. Тело ошибки другое, причина та же.

Исправляется это тем, что ресурсы с собственным циклом создаются в lifespan, а клиент живёт в with на всю сессию тестов:

import pytest
from fastapi.testclient import TestClient
from app import app

@pytest.fixture(scope="session")
def client():
    with TestClient(app) as c:
        yield c

Фикстура поднимает приложение со всеми пулами один раз. Гасит тоже один раз, в конце сессии. Все тесты ходят через один портал и один цикл, как запросы к настоящему серверу.

С флагом lifespan у меня по пути вышла показательная накладка.

В первом прогоне матрицы строка ASGITransport показала lifespan=True, хотя lifespan этот транспорт не запускает. Флаг остался в app.state от предыдущего клиента с with: TestClient не копирует приложение, и всё, что в него положили, переживает клиента.

В наборе тестов это значит, что тест без with проходит, если перед ним случайно отработал тест с with, и падает, когда его запускают отдельно.

Асинхронный тест и lifespan, который никто не запустил

Если тесты асинхронные, TestClient с его отдельным потоком только мешает. Для этого случая документация FastAPI предлагает httpx.AsyncClient с ASGITransport: приложение выполняется в том же цикле, что и тест, и асинхронные фикстуры перестают конфликтовать с запросами.

Третья строка матрицы показывает цену этого решения: lifespan=False.

ASGITransport отправляет приложению только HTTP‑запросы и о событиях старта и остановки ничего не знает. Документация об этом предупреждает в рамке с пометкой Warning и отправляет к пакету asgi‑lifespan.

С ним тест выглядит так:

import httpx, pytest
from asgi_lifespan import LifespanManager
from app import app

@pytest.mark.asyncio
async def test_now():
    async with LifespanManager(app) as manager:
        transport = httpx.ASGITransport(app=manager.app)
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
            r = await client.get("/now")
    assert r.status_code == 200

LifespanManager запускает lifespan в цикле теста, дожидается старта, а на выходе выполняет остановку.

Без него пул из lifespan не появится. Тест упадёт на AttributeError про pool, и по такой ошибке хотя бы сразу видно, чего не хватает.

Подмена зависимостей и общий словарь

До сих пор тесты отправляли настоящий токен. В реальном проекте пользователь приходит из внешнего сервиса авторизации, и в тесте его подменяют через app.dependency_overrides.

Это обычный словарь на объекте приложения: ключ — исходная зависимость, значение — функция, которую FastAPI вызовет вместо неё.

Ключом служит сам объект, по имени FastAPI ничего не ищет.

На этом спотыкаются проверки ролей, сделанные вызываемым классом.

Допустим, есть RoleChecker с методом call и два варианта использования: require_admin = RoleChecker("admin") на уровне модуля и Depends(RoleChecker("admin")) в сигнатуре ручки.

Подмена по ключу RoleChecker не работает ни там, ни там.

Ручка продолжает отвечать 403. Подмена по ключу require_admin работает. А до экземпляра, созданного в сигнатуре, из теста не дотянуться вовсе, потому что на него нет ни одной ссылки, и остаётся подменять зависимость уровнем ниже, get_current_user.

Вторая проблема словаря в том, что он один на весь процесс тестов.

Документация советует выставить подмену в начале теста и очистить в конце, app.dependency_overrides = {}. Если assert между этими строками упадёт, очистка не выполнится.

Я проверил на двух тестах: в первом ошибка в ожидаемом значении, второй проверяет, что анонимный пользователь получает 401. Упали оба. Второй — с assert 201 == 401, потому что подменённый пользователь достался ему по наследству.

Надёжнее отдать очистку фикстуре, тогда она выполнится при любом исходе теста:

from app import app, get_current_user

@pytest.fixture
def as_user():
    def set_user(name="alice"):
        app.dependency_overrides[get_current_user] = lambda: {"name": name}
    yield set_user
    app.dependency_overrides.pop(get_current_user, None)

def test_owner_is_current_user(client, as_user):
    as_user("bob")
    r = client.post("/orders", json={"sku": "A-1", "qty": 2})
    assert r.json()["owner"] == "bob"

Тест сам решает, кем ему быть. Фикстура гарантирует, что следующий начнёт с чистого словаря.

Со случайным порядком тестов или с pytest‑xdist, который раскладывает тесты по процессам, утечка без такой фикстуры ломает то один тест, то другой, в зависимости от того, кто за кем выполнился.

500, которую тест не видит

Вернёмся к колонке /boom. Сервер отвечает на неё 500 с аккуратным JSON. Тест вместо ответа получает KeyError.

Обработчик ошибок отработал в обоих случаях, разница в том, что происходит после него.

В Starlette за код 500 отвечает ServerErrorMiddleware. Она вызывает ваш обработчик, отправляет его ответ и после этого снова бросает исключение.

В исходнике есть ещё объяснение всего этого: это позволяет серверу записать ошибку в лог «или позволяет тестовым клиентам по желанию поднять ошибку внутри теста».

Желание включено по умолчанию, у TestClient параметр raise_server_exceptions=True, и ответ, который уже ушёл бы клиенту, тест не увидит никогда.

Для большинства тестов это очень даже комфортно. Упавшая ручка сразу показывает трассировку.

Но если контракт API обещает клиентам определённый формат ошибки, проверить его можно только отдельным клиентом:

@pytest.fixture(scope="session")
def raw_client():
    with TestClient(app, raise_server_exceptions=False) as c:
        yield c

def test_internal_error_format(raw_client):
    r = raw_client.get("/boom")
    assert r.status_code == 500
    assert r.json() == {"error": "internal"}

Тот же переключатель есть у ASGITransport, там он называется raise_app_exceptions и тоже включён по умолчанию. Поэтому в третьей строке матрицы тоже KeyError.

Редирект, по которому тест сходил сам

Последняя колонка самая непримечательная.

Ручка объявлена как /items/, клиент шлёт POST /items, и FastAPI с настройкой по умолчанию redirect_slashes=True отвечает 307 с адресом со слэшем.

TestClient создаётся с follow_redirects=True, хотя у обычного httpx.Client по умолчанию False. Он идёт по редиректу за вас, и тест видит 201.

Под uvicorn ответ 307 содержит абсолютный адрес, который приложение собирает из того, что знает о запросе: из заголовка Host и схемы.

Схему за прокси uvicorn берёт из X-Forwarded-Proto, но доверяет этому заголовку только от адресов из --forwarded-allow-ips, а по умолчанию это 127.0.0.1,::1.

В контейнере за ingress‑прокси обычно приходит с другого адреса.

Я запустил uvicorn с пустым списком доверенных адресов и отправил тот же запрос с заголовками, которые поставил бы балансировщик:

$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -X POST localhost:8013/items \
    -H 'Host: api.example.com' -H 'X-Forwarded-Proto: https' \
    -H 'content-type: application/json' -d '{"a": 1}'
307 http://api.example.com/items/

Что будет дальше, решает уже то, что слушает 80-й порт. А тест зелёный, потому что внутри TestClient схема всегда http://testserver и редирект выполняется без сети.

Поймать это в тестах просто. Клиент создаётся с follow_redirects=False, и лишний 307 становится красным тестом.

Лечить лучше в приложении — объявлять пути без расхождений со слэшем или выключить redirect_slashes, если клиенты API всё равно ходят по точным адресам из документации.

Доверенные адреса для заголовков прокси стоит настроить в любом случае: от них зависит и схема в редиректе, и адрес клиента, который приложение видит в request.client.

TestClient вызывает функцию, а не ходит по сети

Все четыре расхождения из матрицы растут из одного места:

TestClient не изображает сеть, он вызывает приложение напрямую и подстраивает это под удобство теста: свой цикл на запрос, исключение вместо 500, редирект без участия теста, общий объект приложения со всем, что в нём лежит.

Для теста бизнес‑логики это удобно, а для проверки того, как приложение ведёт себя на краю, нужен клиент, настроенный ближе к серверу.

Обычно всё сводится к трём фикстурам в conftest.py: клиент в with на всю сессию, клиент без проброса исключений для контрактов ошибок и подмена пользователя с очисткой в yield.

Остальное — обычные тесты со статусами и JSON, которые выглядят так же, как в документации.

Только теперь второй запрос в них ведёт себя так же, как первый.

Если при тестировании FastAPI первый запрос проходит, а второй падает, причина может быть в том, как TestClient работает с асинхронными ресурсами. Чтобы разобраться в таких ситуациях и увереннее тестировать API, полезно на практике изучить pytest, механизм зависимостей FastAPI и работу с запросами.

На бесплатных вебинарах, которые пройдут в рамках курса Otus «Python‑разработчик», можно подробнее разобраться в этих инструментах и применить знания в собственных проектах:

  • 26 октября в 20:00 — «Тестирование FastAPI: проверяем API с помощью pytest». Записаться

  • 10 ноября в 20:00 — «Telegram‑бот с нуля на Python: от теории к практике». Записаться

  • 17 ноября в 20:00 — «Зависимости в FastAPI: возможности Depends». Записаться

Комментарии (0)