Тестирование FastAPI: TestClient и pytest
Урок о том, как покрыть FastAPI-приложение автотестами: от синхронного TestClient до подмены зависимостей и тестовой базы данных.
TestClient — это HTTP-клиент из FastAPI, который шлёт запросы прямо в ваше приложение в памяти, без поднятия реального сетевого сервера.
Эндпоинт, который «вроде работает» при ручной проверке через браузер, — это иллюзия надёжности. Стоит изменить одну зависимость или валидацию, и половина API тихо ломается. Тесты превращают страх правок в уверенность: вы рефакторите, а регрессию ловит CI, а не пользователь в проде. FastAPI спроектирован так, что тестировать его удобно: TestClient работает поверх того же приложения, что и боевой сервер, а система зависимостей позволяет подменять базу и внешние сервисы одной строкой.
Зачем это нужно на практике
API живёт в постоянных изменениях: новое поле в схеме, иной код ответа, дополнительная проверка прав. Без тестов каждое изменение — это рулетка. С тестами вы фиксируете контракт: «POST /users с невалидным email возвращает 422», «GET /items/999 отвечает 404». Эти проверки выполняются за секунды и страхуют именно те места, где легко ошибиться, — коды ответов, валидацию Pydantic, авторизацию. Хорошее покрытие окупается на первом же рефакторинге.
TestClient: первые тесты
FastAPI отдаёт TestClient (он построен на библиотеке httpx и Starlette). Вы импортируете своё приложение, оборачиваете его в клиент и делаете обычные HTTP-вызовы — синхронно, без async. Это самый простой и быстрый способ начать.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_item():
response = client.get("/items/42")
assert response.status_code == 200
assert response.json() == {"id": 42, "name": "Notebook"}
def test_create_item_validation():
# тело без обязательного поля name -> Pydantic вернёт 422
response = client.post("/items", json={"price": 10})
assert response.status_code == 422
Запускаются такие тесты через pytest. Обратите внимание: код 422 Unprocessable Entity — это автоматический ответ FastAPI при провале валидации Pydantic, и его полезно проверять явно, чтобы случайно не «ослабить» схему.
Фикстуры pytest
Создавать клиент в каждом тесте — лишний шум. Фикстура pytest готовит объект один раз и отдаёт его тестам. Так вы убираете дублирование и держите настройку в одном месте.
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
# код до yield — подготовка, после yield — очистка
with TestClient(app) as c:
yield c
def test_health(client):
assert client.get("/healthz").status_code == 200
Конструкция with TestClient(app) as c важна: она запускает события жизненного цикла приложения (startup/shutdown или lifespan), чтобы тест работал в тех же условиях, что и боевой запуск. Фикстуры можно вкладывать: одна готовит базу, другая поверх неё — клиент.
Подмена зависимостей: dependency_overrides
Главная суперсила тестов в FastAPI — app.dependency_overrides. Это словарь, который подменяет любую зависимость (Depends) на тестовую версию. Так вы отрезаете тест от реальной БД, внешнего API или текущего пользователя и делаете его быстрым и детерминированным.
from main import app, get_current_user
def fake_user():
# вместо разбора реального токена — фиксированный тестовый юзер
return {"id": 1, "role": "admin"}
def test_admin_endpoint(client):
app.dependency_overrides[get_current_user] = fake_user
response = client.get("/admin/stats")
assert response.status_code == 200
app.dependency_overrides.clear() # обязательно снять подмену
Принцип: в проде get_current_user разбирает JWT и ходит в базу, а в тесте мы возвращаем готовый объект. Приложение этого не замечает — оно просто получает результат зависимости. Не забывайте очищать dependency_overrides после теста (удобно — в фикстуре через yield), иначе подмена «протечёт» в соседние тесты.
Тестовая база данных
Тесты не должны трогать боевую и даже dev-базу: они обязаны быть изолированными и повторяемыми. Типовой приём — поднять отдельную БД (например, SQLite в файле или in-memory, либо отдельную схему Postgres) и подменить зависимость, которая отдаёт сессию.
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app, get_db, Base
engine = create_engine("sqlite:///./test.db", connect_args={"check_same_thread": False})
TestingSession = sessionmaker(bind=engine)
@pytest.fixture
def db_session():
Base.metadata.create_all(bind=engine) # чистая схема
session = TestingSession()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine) # снести после теста
@pytest.fixture
def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
Здесь каждая проверка получает свежую схему: create_all до теста, drop_all после. Тесты перестают зависеть от порядка запуска и остатков чужих данных. Для скорости часто берут SQLite, но если код использует специфику Postgres (например, JSONB), тестовую БД лучше тоже сделать Postgres, чтобы поведение совпадало.
Асинхронные тесты
TestClient синхронен и подходит большинству случаев. Но иногда нужно тестировать асинхронный код «как есть» — например, проверить конкурентные запросы или async-зависимость без обёрток. Тогда берут httpx.AsyncClient и плагин anyio/pytest-asyncio.
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
@pytest.mark.anyio
async def test_async_endpoint():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
response = await ac.get("/items/42")
assert response.status_code == 200
ASGITransport прокидывает запросы напрямую в ASGI-приложение, минуя сеть, — то есть это всё ещё тест в памяти, просто на async-клиенте. Правило простое: начинайте с синхронного TestClient, переходите на AsyncClient только когда без асинхронности тест не написать.
Как это работает под капотом
TestClient не открывает TCP-порт. Внутри он использует ASGI-транспорт: ваш запрос превращается в стандартное ASGI-событие (словарь scope с методом, путём, заголовками) и передаётся прямо в приложение — тот же путь, по которому идёт реальный запрос от uvicorn, только без сетевого слоя. Поэтому тест проходит через всю цепочку: middleware, разбор зависимостей, валидацию Pydantic, ваш обработчик и сериализацию ответа. dependency_overrides работает потому, что FastAPI резолвит зависимости через единый реестр: перед вызовом обработчика он смотрит, нет ли для зависимости подмены в этом словаре, и если есть — берёт её. Контекстный менеджер with TestClient(app) запускает lifespan-события Starlette, поэтому пулы соединений и прочие ресурсы инициализируются как в проде.
Частые ошибки
- Тесты против боевой/dev-базы. Данные портятся, тесты зависят от состояния. Нужна изолированная тестовая БД.
- Забыли очистить
dependency_overrides. Подмена протекает в другие тесты, и они «зеленеют» по ложным причинам. - Не используют контекст-менеджер клиента. Без
withне отрабатываютstartup/lifespan, и тест идёт в неполных условиях. - Не проверяют 422. Случайное ослабление Pydantic-схемы проходит незамеченным.
- Async ради async. Усложняют тесты
AsyncClientтам, где синхронныйTestClientсправился бы. - Общая БД между тестами без сброса. Один тест «загрязняет» данные для другого, появляется зависимость от порядка.
Итоги
TestClientшлёт запросы в приложение в памяти — быстрый синхронный способ тестировать API.- Фикстуры pytest убирают дублирование настройки; используйте
with TestClient(app), чтобы отработал lifespan. app.dependency_overridesподменяет зависимости (БД, пользователь, внешние сервисы); после теста подмену снимайте.- Держите отдельную тестовую базу с чистой схемой на каждый прогон — изоляция и повторяемость.
- Для собственно асинхронных сценариев берите
httpx.AsyncClientсASGITransport, но не усложняйте без нужды.