Любой код рано или поздно ломается — вопрос только в том, узнаете вы об этом из теста или от пользователя. 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 на функцию, которая даёт тестовую сессию.
Зависимость подменяют снаружи, через словарь приложения; эндпоинт об этом не знает, а очистка после теста обязательна.
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 - тест считает обращения к базе и ждёт одно из трёх.
Что почитать дальше
- Dependency Injection во FastAPI — как работает система зависимостей, которую мы подменяем в тестах.
- Persistence: SQLAlchemy и Alembic — работа с базой данных и миграции для тестовой базы.