Тестирование 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, но не усложняйте без нужды.
Проверьте себя
1. Чем TestClient из FastAPI принципиально отличается от обычного HTTP-клиента к запущенному серверу?
AОн шифрует запросы перед отправкой
BОн отправляет запросы прямо в приложение в памяти через ASGI, без поднятия реального сетевого сервера
CОн работает только с асинхронными эндпоинтами
DОн автоматически создаёт тестовую базу данных
2. Зачем в тестах FastAPI используют app.dependency_overrides?
AЧтобы ускорить запуск pytest
BЧтобы подменить зависимости (например, сессию БД или текущего пользователя) на тестовые версии
CЧтобы автоматически генерировать тестовые данные
DЧтобы отключить валидацию Pydantic
3. Почему важно оборачивать TestClient в контекст-менеджер (with TestClient(app) as c)?
AЧтобы запросы шли быстрее
BЧтобы отработали события жизненного цикла приложения (startup/shutdown/lifespan)
CЭто требование pytest
DЧтобы автоматически очистить dependency_overrides