← назад к разделу

Любой код рано или поздно ломается — вопрос только в том, узнаете вы об этом из теста или от пользователя. FastAPI изначально проектировался так, чтобы тесты было легко писать: зависимости через Depends подменяются одной строкой, всё приложение поднимается в памяти без сети. Разберём инструменты по порядку.

Обязательно

TestClient: самый простой способ протестировать эндпоинтспросят на собеседовании

Представьте, что вы хотите проверить: «если я отправлю GET /products/1, придёт статус 200 и нужный JSON». Для этого нужно как-то «постучать» в приложение. Поднимать настоящий сервер на порту — долго и неудобно. 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 внутри. Для большинства тестов эндпоинтов этого достаточно.

Одна оговорка: TestClient(app), созданный просто так, не запускает lifespan — пул соединений и клиенты, которые вы поднимаете на старте, в таком тесте не существуют. Поэтому клиент открывают контекстным менеджером, with TestClient(app) as client: — тогда startup и shutdown выполняются как в бою; в фикстуре ниже сделано именно так.

httpx.AsyncClient: когда тест сам должен быть асинхронным

Иногда тесту нужно не только послать запрос, но и поработать с async-кодом напрямую: например, подготовить данные через async-репозиторий или проверить что-то через async-сессию базы. Тут TestClient не подойдёт — он синхронный.

Решение: httpx.AsyncClient с ASGITransport. Он подключается к приложению напрямую, тоже без сети, но сам полностью асинхронный.

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

Асинхронные тесты требуют пакет pytest-asyncio и пометку @pytest.mark.asyncio (или глобальную настройку asyncio_mode = "auto" в pytest.ini). ASGITransport lifespan тоже не запускает; если он нужен, приложение оборачивают в LifespanManager из пакета asgi-lifespan.

Подмена зависимостейспросят на собеседовании

Это главная суперсила тестирования во FastAPI. Допустим, эндпоинт получает сессию базы данных через Depends(get_session). В тесте не нужно трогать продакшн-базу — достаточно подменить get_session на функцию, которая даёт тестовую сессию.

тест: dependency_overrides[get_session] = test_session код эндпоинта не меняется TestClient(app) запрос как от клиента эндпоинт получает тестовую сессию иначе подмена утечёт в соседний тест после теста: overrides.clear()

Зависимость подменяют снаружи, через словарь приложения; эндпоинт об этом не знает, а очистка после теста обязательна.

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 — это обычный словарь: ключ — оригинальная функция-зависимость, значение — замена. Эндпоинт ничего не знает о подмене, он продолжает получать зависимость через Depends как обычно.

После теста подмену нужно убрать, иначе она повлияет на следующие тесты:

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: диалекты расходятся, и то, что работает в SQLite, может не работать в Postgres.

Два распространённых подхода:

  • Отдельная тестовая база — перед прогоном тестов применяют миграции Alembic в чистую базу. Удобно запускать через Docker: pytest поднимает контейнер с PostgreSQL, применяет миграции, гоняет тесты, контейнер останавливается. Библиотека pytest-docker или testcontainers помогают автоматизировать это.

  • Транзакция с откатом — каждый тест открывает транзакцию и откатывает её в конце. База остаётся чистой, тесты не видят данных друг друга. Работает быстрее, потому что данные не пишутся физически.

Поднимать базу в контейнере — стандартная практика. Так проверяется и схема, и реальные SQL-запросы, а не их имитация.

Дополнительно: при первом чтении можно пропустить

Глубже: ошибки 500, внешние вызовы и подмена зависимостейрасширенное

TestClient по умолчанию не возвращает 500, а поднимает исключение сервера прямо в тест: так удобнее искать причину, но невозможно проверить собственный обработчик ошибок. Для тестов на «что увидит клиент при падении» клиент создают с TestClient(app, raise_server_exceptions=False) и проверяют статус и тело.

Исходящие HTTP-вызовы через httpx в тестах перехватывают пакетом respx: он подменяет транспорт и отвечает заготовленными ответами по маршруту, включая таймауты и 5xx, — так проверяют ветки отказа клиента без сети. Вызовы к базе не подменяют, а гоняют на настоящем PostgreSQL в контейнере: сессия-заглушка проверяет не запросы, а ваши догадки о них.

Зависимость с yield (сессия, клиент) подменяют такой же функцией с yield, и FastAPI выполнит её часть «после» так же, как в бою. Подменять нужно именно тот объект функции, который стоит в Depends(...): если роутер импортировал get_session из одного модуля, а тест переопределяет одноимённую функцию из другого, подмена молча не сработает.

Коротко

  • TestClient — самый простой способ тестировать эндпоинты: поднимает приложение в памяти, работает в обычных def-тестах.
  • httpx.AsyncClient с ASGITransport — когда тест должен быть асинхронным; требует pytest-asyncio.
  • app.dependency_overrides — словарь для подмены зависимостей в тестах; после теста его нужно очищать.
  • Фикстуры pytest собирают окружение один раз и переиспользуют: клиент, подмены, уборка.
  • Тестируйте с реальной базой того же типа (PostgreSQL, не SQLite) — через отдельную тестовую базу или транзакцию с откатом.
  • TestClient без with не запускает lifespan, а ошибки сервера поднимает в тест: для проверки 500 нужен raise_server_exceptions=False; внешние HTTP-вызовы подменяет respx.

Что пощупать

Тесты стартового каталога практикума remodov/marketplace-system-python идут на настоящем PostgreSQL: фикстура stand на всю сессию поднимает приложение через asgi-lifespan и ходит в него httpx.AsyncClient с ASGITransport. Подмена сервиса в app.state на наследника UnitOfWork с подсчётом обращений доказывает, что кэш работает, а доменные правила проверяются без приложения.

Код: conftest.py, test_cache.py, test_rules.py.

Сделаем сами

Ветка step-06-search-and-cache - тест считает обращения к базе и ждёт одно из трёх.

Что почитать дальше