Тесты в FastAPI: рабочие приёмы и типичные ошибки
Писать эндпоинты в FastAPI быстро, а вот покрывать их тестами — уже не так очевидно. Разберём, как устроен тестовый клиент, зачем подменять зависимости и почему тесты на реальной базе ломаются в CI чаще, чем в локальном терминале.

Почему «просто requests» не работает
Первое, что приходит в голову — поднять сервер и ударить по нему requests.get. Технически это работает, но вы получаете интеграционный тест, который зависит от порта, от того, запущен ли Uvicorn, и от состояния базы. В CI такой тест падает через раз, а отлаживать его — удовольствие ниже среднего.
FastAPI предлагает другой путь: TestClient из Starlette, который вызывает ASGI-приложение напрямую, без сети. А для асинхронных сценариев — httpx.AsyncClient с транспортом ASGITransport. Ниже — конкретные приёмы, которые экономят часы отладки.
Синхронный TestClient: быстрый старт
TestClient оборачивает ваше приложение и позволяет слать запросы без запуска сервера. Под капотом — httpx, поэтому API совпадает с привычным HTTP-клиентом.
# test_sync.py
# FastAPI 0.115.x, Python 3.11+
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
client = TestClient(app)
def test_health_returns_ok():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
Запуск: pytest test_sync.py -v. Никакого Uvicorn, никакого порта. Тест проходит за миллисекунды.
Но у TestClient есть ограничение: он оборачивает асинхронные хендлеры в синхронный вызов через внутренний цикл событий. Если в коде используются async def-зависимости, WebSocket или SSE — переходите к асинхронному клиенту.
Асинхронный клиент через httpx
Начиная с FastAPI 0.100+ рекомендуемый способ асинхронного тестирования — httpx.ASGITransport. Он позволяет писать async-тесты напрямую, без обёрток.
# test_async.py
import pytest
import httpx
from httpx import ASGITransport
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id}
@pytest.mark.anyio
async def test_read_item():
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as ac:
response = await ac.get("/items/42")
assert response.status_code == 200
assert response.json() == {"item_id": 42}
Для работы pytest.mark.anyio нужен пакет anyio и плагин pytest-anyio (или pytest-asyncio с маркером pytest.mark.asyncio). Выбор зависит от вашего стека — оба варианта рабочие.
Подмена зависимостей: dependency_overrides
Реальная проблема начинается, когда эндпоинт тянет данные из базы, ходит во внешний сервис или проверяет JWT. Тащить всё это в юнит-тест — значит превратить его в интеграционный.
FastAPI решает это через словарь app.dependency_overrides: вы указываете, какую зависимость чем заменить, и приложение использует подмену вместо оригинала.
# test_override.py
from fastapi import FastAPI, Depends
from fastapi.testclient import TestClient
app = FastAPI()
def get_db():
# В проде здесь — сессия SQLAlchemy или другой коннект
raise RuntimeError("Не должен вызываться в тесте")
@app.get("/users/{user_id}")
def read_user(user_id: int, db=Depends(get_db)):
# Упрощённо: в реальности — запрос к базе
return {"user_id": user_id, "name": "from_db"}
# --- тест ---
def fake_get_db():
return "fake_session"
app.dependency_overrides[get_db] = fake_get_db
client = TestClient(app)
def test_read_user_with_fake_db():
response = client.get("/users/1")
assert response.status_code == 200
# Хендлер получит fake_session вместо реального коннекта
После теста не забывайте чистить словарь: app.dependency_overrides.clear(). Иначе подмена утечёт в следующий тест, и вы будете полчаса искать, почему он вдруг проходит (или падает) не так, как ожидалось.
Самая частая ошибка с dependency_overrides — использовать другой объект приложения. Если в тестовом файле вы импортировалиappиз одного модуля, а эндпоинты зарегистрированы на другом экземпляре, подмена не сработает. Одинapp— одно приложение — одни оверрайды.
База данных: три подхода
Тесты, которые трогают базу, — источник боли номер один. Вот три стратегии с реальными компромиссами.
| Подход | Скорость | Достоверность | Сложность настройки |
|---|---|---|---|
| Мок зависимости (dependency_overrides) | Высокая | Низкая — не проверяет SQL | Минимальная |
| SQLite in-memory вместо PostgreSQL | Высокая | Средняя — часть SQL-диалекта не совпадает | Средняя |
| Тестовая PostgreSQL с откатом транзакции | Средняя | Высокая | Высокая — нужен отдельный контейнер в CI |
Откат транзакции как фикстура
Идея: каждый тест работает внутри транзакции, которая откатывается в конце. Данные не накапливаются, тесты не мешают друг другу. Схематично фикстура выглядит так:
# conftest.py — схематично, адаптируйте под свою версию SQLAlchemy
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
TEST_DATABASE_URL = "postgresql://test:test@localhost:5432/test_db"
engine = create_engine(TEST_DATABASE_URL)
TestSession = sessionmaker(bind=engine)
@pytest.fixture()
def db_session():
connection = engine.connect()
transaction = connection.begin()
session = TestSession(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
Затем через dependency_overrides подставляете эту сессию вместо продовой. Конкретный синтаксис зависит от версии SQLAlchemy (1.4 / 2.x) — сверяйтесь с официальной документацией по работе с сессиями и транзакциями.
Частые ошибки и как их распознать
- Тесты проходят локально, падают в CI. Обычно причина — порядок запуска. Один тест оставляет данные или не чистит
dependency_overrides, следующий ловит чужое состояние. Лечение: изолируйте фикстуры и запуститеpytest --randomly-seed=12345(плагинpytest-randomly), чтобы воспроизвести порядок. - 422 Unprocessable Entity вместо ожидаемого ответа. FastAPI валидирует входные данные жёстко. Если тест отправляет JSON без обязательного поля или с неверным типом, вы получите 422 — и это правильное поведение. Проверяйте тело ответа: поле
detailпокажет, какое именно поле не прошло валидацию. - Lifespan-события не срабатывают.
TestClientзапускает lifespan при использовании как контекстный менеджер:with TestClient(app) as client:. Безwithstartup и shutdown не вызываются — и всё, что вы инициализируете в lifespan (пул соединений, кеш), окажется пустым. - Тест async-эндпоинта зависает. Частая причина — смешивание синхронного
TestClientсasync-генераторами зависимостей. Переходите наhttpx.AsyncClientсASGITransport. - Файловый аплоад возвращает 400.
TestClientпринимает параметрfilesв формате httpx. Не передавайте файл черезjson=— это другой content-type.
Что тестировать, а что нет
Покрывать каждый эндпоинт тестом на 200 — бессмысленная работа. FastAPI и Pydantic уже гарантируют, что невалидный запрос вернёт 422. Вот на чём стоит сосредоточиться:
- Бизнес-логика за эндпоинтом: правильные вычисления, корректные переходы состояний.
- Граничные случаи валидации: пустой список, отрицательный ID, строка длиной в мегабайт.
- Авторизация: эндпоинт без токена возвращает 401 или 403, а не 200 с чужими данными.
- Ошибки зависимостей: что происходит, когда база недоступна или внешний сервис отвечает 500.
Тесты на «контроллер возвращает 200 для валидного запроса» нужны ровно для одного: убедиться, что роутинг и сериализация работают. Один тест на happy path, остальные — на то, что может сломаться.
Сноска: ASGI
ASGI (Asynchronous Server Gateway Interface) — спецификация взаимодействия между Python-веб-приложением и сервером. Если WSGI обрабатывает один запрос за вызов, то ASGI поддерживает асинхронные соединения, WebSocket и фоновые задачи. FastAPI работает поверх ASGI, и именно поэтому тестовый клиент может вызывать приложение без HTTP-сервера — он напрямую передаёт ASGI-сообщения.
Что делать дальше
После того как базовые тесты работают, следующий шаг — встроить их в CI. Для тестов с реальной PostgreSQL удобнее всего сервис-контейнер (GitHub Actions: services: postgres:, GitLab CI: services:). Замерьте покрытие через pytest-cov, но не гонитесь за процентами — 80% покрытия с осмысленными кейсами лучше 95% с тестами, которые проверяют только status code. Следите за временем прогона: если тесты идут дольше минуты на небольшом проекте, ищите утечки соединений и лишние фикстуры. И перечитайте раздел официальной документации FastAPI по тестированию — там есть примеры для WebSocket, загрузки файлов и OAuth2-потоков, которые выходят за рамки этого материала.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: FastAPI — официальная документация по тестированию, httpx — документация по AsyncClient и транспортам
Частые вопросы
Чем TestClient отличается от httpx.AsyncClient для тестов FastAPI?
Как подменить зависимость, если она объявлена в другом роутере?
Почему тест возвращает 422 вместо ожидаемой ошибки?
Как запускать тесты, если у меня другая версия FastAPI или Python?
Тесты с базой данных работают локально, но падают в CI — что делать?
Нужно ли тестировать автоматическую валидацию Pydantic отдельно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.