В тестах FastAPI нет ресурса из lifespan: запускаем TestClient через with
API создаёт общий HTTP-клиент в lifespan, а обработчик берёт его из app.state. При обычном запуске всё работает. В тесте возникает AttributeError: клиент не существует. Подмена данных в тесте может убрать ошибку, но оставить непроверенным настоящий запуск приложения.
Причина часто в способе создания TestClient. Чтобы тест выполнил lifespan, используйте его как менеджер контекста: with TestClient(app) as client. Вход в блок запускает подготовку приложения, выход — освобождение ресурсов. Запрос внутри блока видит тот же порядок жизненного цикла, на который рассчитывает обработчик.
Выделите ресурс, который принадлежит приложению
Общий клиент внешнего API удобнее создавать один раз на время жизни приложения. Тогда соединения могут переиспользоваться, а настройка транспорта и тайм-аутов находится в одном месте. В lifespan код до yield готовит ресурс, после yield освобождает его. Сам обработчик занимается запросом, не создавая новый пул соединений на каждое обращение.
В примере ресурсом служит настоящий httpx.AsyncClient. Вместо внешней сети используется MockTransport, который возвращает каталог из двух записей. Это позволяет проверить и создание клиента, и обращение к нему из асинхронного маршрута, и закрытие после завершения теста.
Запустите тест в правильных границах
Сохраните код в lifespan_demo.py. Нужны FastAPI и HTTPX. До входа в with ресурс отсутствует. Во время запроса он открыт. После выхода проверка is_closed подтверждает закрытие клиента, а запись из app.state удалена, чтобы дальнейший код не использовал уже освобождённый объект.
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
created = []
def upstream_handler(request):
assert request.url.path == "/catalog"
return httpx.Response(200, json={"items": ["A-1", "B-2"]})
@asynccontextmanager
async def lifespan(app):
async with httpx.AsyncClient(
base_url="https://catalog.example.test",
transport=httpx.MockTransport(upstream_handler),
timeout=5,
) as upstream:
created.append(upstream)
app.state.upstream = upstream
try:
yield
finally:
del app.state.upstream
app = FastAPI(lifespan=lifespan)
@app.get("/catalog")
async def catalog(request: Request):
upstream = request.app.state.upstream
response = await upstream.get("/catalog")
response.raise_for_status()
return response.json()
assert not hasattr(app.state, "upstream")
with TestClient(app) as client:
assert len(created) == 1
assert not created[0].is_closed
response = client.get("/catalog")
assert response.status_code == 200
assert response.json() == {"items": ["A-1", "B-2"]}
assert created[0].is_closed
assert not hasattr(app.state, "upstream")
print({"created": len(created), "closed_after_test": created[0].is_closed})Ожидаемый результат — один созданный клиент и closed_after_test=true. Проверка выполняет реальный lifespan FastAPI, но не подключается к каталогу: HTTPX передаёт обращение локальному транспорту. В рабочем приложении достаточно заменить транспорт и настройки адреса, сохранив границы владения клиентом.
Не держите клиент за пределами своего цикла
Асинхронные ресурсы могут зависеть от цикла событий, в котором созданы. Поэтому глобальный AsyncClient на уровне импорта усложняет тесты и перезапуск приложения. В одном тесте он работает, в другом оказывается закрытым либо привязанным к завершённому циклу. Создание внутри lifespan связывает его с конкретным экземпляром запуска.
Для нескольких тестов вынесите with TestClient(app) в fixture с yield. Код до yield открывает клиент, после — выполняет выход из контекста. Выберите область fixture по смыслу тестов: общий запуск быстрее, отдельный запуск лучше обнаруживает утечки состояния. Не делайте глобальный список created частью production-кода; здесь он нужен только для явного наблюдения за жизненным циклом.
Проверьте не только успешный запрос
Если подготовка ресурса падает до yield, приложение не должно считаться готовым. В отдельном тесте подставьте транспорт или фабрику, которая выбрасывает ошибку при инициализации, и ожидайте отказ входа в TestClient. Так вы проверите поведение запуска, вместо того чтобы узнать о нехватке настроек на первом пользовательском запросе.
При нескольких ресурсах используйте вложенные менеджеры контекста либо AsyncExitStack. Если создание второго ресурса завершится ошибкой, первый всё равно должен закрыться. Закрывающие действия, написанные только после yield без соответствующего try/finally или менеджера контекста, легко пропустить при исключении.
Для API с несколькими workers учитывайте ещё одну границу: lifespan выполняется для каждого процесса. «Один клиент на приложение» не означает один клиент на все процессы сервера. Это важно при выборе размера соединительных пулов и лимитов внешнего сервиса. В тесте выше намеренно один экземпляр приложения и один запуск.
Практику маршрутов и локальных HTTP-тестов можно продолжить в бесплатном курсе FastAPI.
Не смешивайте несколько способов запуска
При переходе на lifespan проверьте старые обработчики startup и shutdown. Если часть ресурсов всё ещё создаётся старым способом, тест может зависеть от того, какая конфигурация приложения используется. Соберите подготовку в одном явном жизненном цикле и проверьте каждый ресурс внутри with. Подмена зависимости маршрута сама по себе не запускает и не отменяет lifespan: это разные уровни приложения.
Если вместо TestClient вы вызываете приложение через httpx.ASGITransport, не переносите ожидание автоматического запуска без проверки. HTTP-транспорт и управление жизненным циклом — отдельные задачи; для асинхронных тестов понадобится явно организовать lifespan. В первую очередь сохраните три утверждения из примера: до запуска ресурса нет, во время запроса он открыт, после остановки закрыт. Они помогают обнаружить проблему даже при смене тестового клиента.
Для проверки готовности сервиса используйте результат успешной инициализации, а не только существование процесса. Иначе балансировщик направит запрос в worker, который ещё не подготовил нужный клиент или кэш. Тест отказа запуска дополняет успешный HTTP-сценарий и делает это условие явным.
Разберитесь с владением асинхронными ресурсами
Следующий шаг — управлять несколькими клиентами и корректно освобождать их при исключениях. На платном курсе «Python для профессионалов» вы будете проектировать менеджеры контекста, писать код с async/await и asyncio, профилировать I/O-нагрузку. Это помогает понимать, кто создаёт ресурс, как долго он живёт и где должен завершаться его жизненный цикл. Откройте программу платного курса «Python для профессионалов».
Продолжить на практике
- Python для профессионалов ↗
Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.
