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. Сервис, который так тестируется, продукт-инженер может менять без страха — обратная связь от тестов приходит за секунды, а не на проде.