Привет, Хабр!
Принято считать, что тесты в 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». Записаться