FastAPI задуман тестируемым, и главная причина этого — зависимости: всё, что ходит наружу, подменяется одной строкой, не трогая код эндпоинтов. Это позволяет писать тесты, которые проходят весь путь от HTTP до Handler-а, но с подконтрольным окружением.
TestClient: синхронный путь
Самый простой инструмент — TestClient: он поднимает приложение в памяти и шлёт ему запросы без сети.
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_get_product():
response = client.get("/products/1")
assert response.status_code == 200
assert response.json()["id"] == 1
TestClient синхронный (его можно звать из обычного def-теста), хотя приложение асинхронное — он сам крутит event loop внутри. Для большинства тестов эндпоинтов этого достаточно.
httpx.AsyncClient: асинхронный путь
Когда тесту нужно само быть асинхронным — например, чтобы вызывать async-репозиторий или работать с async-сессией базы напрямую, — берут httpx.AsyncClient поверх ASGI-приложения.
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_get_product():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/products/1")
assert response.status_code == 200
ASGITransport(app=app) — актуальный способ подключить httpx к приложению напрямую, без сети; асинхронные тесты требуют pytest-asyncio.
Переопределение зависимостей
Ключ к тестам — app.dependency_overrides: подменить базу на тестовую, внешний клиент — на заглушку. Эндпоинт не знает о подмене, потому что получает всё через Depends.
async def override_get_session():
async with test_session_factory() as session:
yield session
app.dependency_overrides[get_session] = override_get_session
После теста подмену снимают (app.dependency_overrides.clear()), чтобы тесты не влияли друг на друга. Это удобно вынести в фикстуру.
Фикстуры pytest
Фикстуры собирают окружение теста — клиент, сессию, подмены — и наводят порядок после.
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.db import get_session
@pytest.fixture
def client():
app.dependency_overrides[get_session] = override_get_session
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
def test_create_product(client):
response = client.post("/products/", json={"name": "Кофемолка", "price": 4990})
assert response.status_code == 201
Тестовая база данных
Тесты, которые трогают данные, должны бить в реальную базу того же типа (PostgreSQL — в PostgreSQL, не в SQLite: диалекты расходятся), но изолированно. Два рабочих подхода: отдельная тестовая база, которую миграции Alembic поднимают перед прогоном, или оборачивание каждого теста в транзакцию с откатом в конце — тогда тесты не видят данных друг друга и база остаётся чистой. Поднимать настоящую базу в контейнере на время тестов — обычная практика; так проверяется и схема, и запросы, а не их имитация.
Где это в UCP
Подменяемость через Depends задаёт пирамиду тестов сервиса: быстрые тесты Handler-ов и домена с подменёнными портами — снизу, тесты эндпоинтов через клиент с тестовой базой — посередине, немного сквозных — сверху. Логику не тестируют через HTTP без нужды, а HTTP-слой не дублируют в каждом тесте — каждый уровень проверяет своё. Это та же стратегия, что и в Spring-биндинге со слайс-тестами и TestContainers, только инструментами Python. Сервис, который так тестируется, продукт-инженер может менять без страха — обратная связь от тестов приходит за секунды, а не на проде.