Прямой ответ
Тестирование FastAPI строится на трёх китах. (1) TestClient / httpx.ASGITransport — запросы к приложению in-process (без сети и запущенного сервера — быстро и детерминировано): client = TestClient(app) → r = client.get("/orders/1") → assert по статусу/телу — полный цикл запрос-ответ, включая валидацию, зависимости, middleware, сериализацию (это уже интеграционный тест, а не unit — граница HTTP покрыта целиком); для асинхронных фикстур/лайфсайкла — httpx.AsyncClient(transport=ASGITransport(app=app), base_url="") + pytest-asyncio (startup/shutdown события, SSE/стримы, фоновые задачи — асинхронный клиент честнее). (2) dependency_overrides — штатная подмена зависимостей: app.dependency_overrides[get_db] = fake_db — DI-система (зависимости) спроектирована для тестируемости — подменять можно БД-сессию, текущего пользователя (тест авторизации без реальной регистрации — override get_current_user на фикстуру-пользователя), внешние клиенты (платёжный SDK — фейк); фикстура-обёртка с cleanup (yield + очистка overrides — утечка override в соседний тест — «тесты по отдельности зелёные, вместе красные» — классика) — override, не monkeypatch внутренностей (переопределение границы стабильнее патчинга реализации — мок-паттерны). (3) Реальная тестовая БД (не SQLite-подмена «чтобы быстрее» — диалектные расхождения JSONB/array/ON CONFLICT — тесты врут): testcontainers-Postgres на сессию (контейнеры), схема — миграциями (Alembic — тесты гоняют реальные миграции — «миграция сломана» ловится тестами), изоляция теста — транзакция с rollback (сессионная фикстура: connection → transaction → сессия теста → rollback — данные не копятся, тесты независимы, скорость высокая; альтернатива — truncate между тестами — проще, медленнее). Внешние HTTP — respx-моки httpx (детерминированные ответы партнёров — сценарии ошибок/таймаутов проверяются наравне с успехом) или стаб-сервис в контейнере (контракт важнее скорости — контрактные тесты). Пирамида в числах: unit сервисного слоя (сотни, миллисекунды — без TestClient вовсе), API-интеграционные (десятки-сотни — TestClient+БД), e2e (Playwright — единицы-десятки критических путей).
TestClient: механика и лайфсайкл
TestClient (Starlette-обёртка над httpx) — синхронный фасад к ASGI-приложению: запросы исполняются в event loop внутри (async-эндпоинты работают прозрачно), сессии/куки сохраняются между запросами (сценарии логина — client.post("/login") → последующие запросы с кукой — без ручных заголовков); with TestClient(app) as client: — контекст запускает lifespan (startup/shutdown — подключение пулов/фоновых задач — без with lifespan не исполняется — «в тестах нет пула БД» — отсюда); фоновые задачи (BackgroundTasks) — TestClient исполняет их до возврата ответа (проверка побочных эффектов возможна сразу — в проде они асинхроннее — семантику помнить); стримы/SSE — асинхронный клиент с iter_lines (TestClient-поддержка стримов ограничена — SSE-тестирование — AsyncClient-паттерн); WebSocket — client.websocket_connect("/ws") (встроенная поддержка — WebSocket-приложения тестируются тем же инструментом); проверка контракта — assert по r.json() против Pydantic-схемы ответа (Schema.model_validate(r.json()) — тест ловит дрейф контракта — OpenAPI — схема и тест из одного источника); типичные проверки: статус-коды (REST-семантика — 201/404/422 по сценариям), валидация 422 с деталями полей, авторизация 401/403 (без токена/чужой ресурс — OWASP — горизонтальный доступ — обязательный тест-кейс), идемпотентность повторов (ключи), пагинация/фильтры (списки — граничные страницы).
Зависимости: overrides, БД-фикстуры, авторизация
Dependency_overrides — карта {оригинальная зависимость: подмена}: get_db → тестовая сессия (фикстура ниже), get_current_user → фиксированный пользователь (тесты ролей — разные фейк-пользователи на сценарии — админ/юзер/чужой), платёжный клиент → фейк с программируемыми исходами (успех/отказ/таймаут — retry-сценарии проверяются без партнёра); дисциплина: overrides живут в фикстуре с очисткой (function-scope yield-фикстура — после теста app.dependency_overrides.clear() — иначе подмена протекает в следующие тесты — порядок зависимостей в pytest неочевиден — баги-призраки); подменяем границы (БД/внешние/время/рандом), не внутреннюю логику (подмена service-функции внутри своего же приложения — тест проверяет мок, не код — антипаттерны моков); время/uuid — инъекция через зависимости (now()-зависимость — фейк-время в тесте — детерминизм без freeze-библиотек — DI-нативный подход). БД-фикстура (транзакционный паттерн): session-скоуп — engine/testcontainers; function-скоуп — connection → begin (внешняя транзакция) → сессия приложения привязана к connection (join through — get_db override отдаёт эту сессию) → после теста rollback внешнего — БД чиста; нюанс — вложенные commit'ы приложения внутри внешней транзакции (SAVEPOINT-семантика — begin_nested — классическая фикстура FastAPI-доков использует именно это); фабрики данных (factory-подход) — объекты тестов создаются фабриками (UserFactory/OrderFactory — тесты читаются, данные вариативны); «полная копия прода в тестах» — не нужна (достаточно структурной схемы — данные синтетические — персональные в тестах запрещены — гигиена).
Практический сценарий: API без тестов → покрытие за итерацию
Исходник: сервис заказов (FastAPI+Postgres, 22 эндпоинта) — тестов нет («проверяем руками на стейдже» — регрессии еженедельные — регресс-поиск стал рутиной — симптом запущенности). Внедрение (спринт): (1) каркас — conftest: testcontainers-Postgres (session), Alembic-миграции (session), транзакционная БД-фикстура (function), TestClient-фикстура с dependency_overrides (get_db, get_current_user) и очисткой, фабрики (User/Order/Product — фабрики); (2) первый слой — happy path по каждому эндпоинту (22 теста — CRUD-сценарии — статусы/тела/схема-валидация — покрытие «основного потока» за 2 дня); (3) второй слой — ошибки и границы: 422 на невалидные payload (параметризация — pytest-параметризация — один тест-функция, дюжина кейсов), 401/403 (нет токена/чужой заказ — горизонтальный доступ — нашлась реальная дыра: GET /orders/{id} не проверял владельца — баг прод-уровня закрыт тестом на второй день — тесты окупились до завершения внедрения), 404, пагинация-границы, идемпотентность POST с ключом; (4) внешние — respx на httpx-клиенты (платёжка: успех/decline/timeout/500 — поведение API на каждый — ретраи и деградационные ветки проверены — одна ветка оказалась недостижимой мёртвым кодом — удалена); (5) скорость — весь набор 40 секунд (контейнер БД один на сессию, транзакционная изоляция — truncate не понадобился), CI-джоб на PR (пайплайн — кеш зависимостей — джоб 2.5 мин); (6) культура — «новый эндпоинт без тестов не мержится» (ревью-чек-лист) + ночной прогон с locust-смоуком (интеграция с реальной сетью — TestClient не ловит сетевые/прокси-эффекты — смоук дополняет). Итог квартала: 180 API-тестов, регрессии на проде — 2 против ~12 за предыдущий квартал (обе — вне API-слоя: миграция данных и конфиг — зоны, покрытые позже), рефакторинг сервисного слоя прошёл без страха (тесты как страховка — рефакторинг ускорился кратно); замер ценности: средняя стоимость найденного тестом бага — минуты против часов прод-разбора (инцидент-метрики).
FAQ
TestClient против реального запущенного сервера в тестах? TestClient — in-process ASGI (без сети/портов — быстро, параллельно-безопасно, отладка в том же процессе — 95% задач); реальный uvicorn в тесте (subprocess-фикстура) — нужен для: сетевых эффектов (прокси/таймауты — за nginx), мультипроцессного поведения (воркеры/префорк — gunicorn), нагрузочных прогонов (locust против живого порта) и e2e с браузером (Playwright — против реального URL); пирамида: TestClient — основной слой, живой сервер — точечно на сетевое/процессное; «все тесты против живого сервера» — медленно и хрупко (порты/гонки старта) — оправдано только для e2e-минимума.
Как тестировать фоновые задачи и Celery-вызовы из API?
Варианты по слою: BackgroundTasks — TestClient исполняет синхронно до ответа (побочный эффект проверяется сразу — просто); Celery — в тестах режим eager (task_always_eager=True — задача исполняется inline, без брокера — быстро и детерминированно — но без реальной сериализации/очереди) — юнит-слой; интеграция с брокером — testcontainers-Redis/Rabbit (контейнеры) + проверка постановки (inspect/результат — «задача в очереди с верными аргументами»); «эмуляция вызова моком send» — проверяет факт вызова, не исполнение — комбинируйте (мок на постановку в API-тестах + отдельные тесты самой задачи — Celery-надёжность — слои разные).
Нужно ли мокировать свою базу в API-тестах? Нет (почти никогда): БД в API-тесте — часть тестируемого контракта (миграции/запросы/транзакции/ограничения — UNIQUE/FK ловят баги, которые мок пропустит — «тесты с мок-БД зелёные, прод лежит на констрейнте» — частая история); testcontainers-Postgres стоит секунды на сессию — экономия мока мнимая; моки БД уместны в чистых unit-тестах сервисов вне API-контекста (логика без хранения — in-memory репозиторий-фейк — слоистая структура — интерфейс репозитория подменяется тривиально); SQLite-замена Postgres — отдельный антипаттерн (диалектные различия — тесты проходят, прод нет — SQL-специфика в SQLite отсутствует вовсе).
Как поддерживать тесты при изменении API-контракта?
Контракт-первый подход: Pydantic-схемы — источник (тесты валидируют ответы схемами — Schema.model_validate(r.json()) — изменение схемы ломает тесты осознанно — дрейф невозможен); OpenAPI-снимок в репо + тест-дифф (схема изменилась — PR показывает breaking-изменения — версионирование — контрактные тесты потребителей — openapi-практика); параметризованные тесты сценариев (один кейс — одна строка параметра — изменение охватывается правкой данных, не дублированием кода); «тесты на каждый чих реализации» — хрупкость (тесты, проверяющие приватные детали, ломаются на безобидном рефакторинге — тестировать поведение/контракт — мок-границы — поведение стабильнее реализации).
Источники: документация FastAPI (testing, testing-database), pytest fixtures — ссылки в шапке.