FastAPI Dependency Injection: тестируем правильно
ТехнологииService Lab.
Fast API предоставляет мощный инструмент для внедрения зависимостей (Dependency Injection), который часто недооценивают при написании тестов. В отличие от Django или Flask, Fast API позволяет легко подменять реальные зависимости на моки и фикстуры. Это делает тестирование сервисного слоя практически прозрачным: вы можете изолировать репозитории, внешние API и даже базу данных без изменения бизнес-логики.
Мы уже разбирали, как async влияет на производительность Django 5 — здесь же сфокусируемся на FastAPI и его встроенном механизме внедрения зависимостей.
Dependency Injection в Fast API работает через механизм Depends. Вы декларативно указываете, что функция или класс нуждается в определённой зависимости, и фреймворк сам обеспечивает её предоставление. Если вы создадите экземпляр зависимости внутри функции, тестировать код станет сложнее — придётся использовать глобальное состояние, замокать глобальные переменные или переопределять настройки на уровне приложения.
Когда Dependency Injection улучшает тестируемость
Dependency Injection становится действительно полезным, когда у вас есть внешние или тяжёлые зависимости. Представьте репозиторий для работы с базой данных, клиент для внешнего API, или SaaS-сервис авторизации. В рамках интеграционного теста или нагрузки вы захотите заменить их на фейковые реализации. Без DI вам пришлось бы либо выносить все эти зависимости в глобальный модуль, либо городить хитрые паттерны типа Abstract Factory.
В Fast API это можно сделать очень элегантно. Есть два подхода: либо описать зависимость в виде async def-функции с соответствующей аннотацией, либо создать класс с __call__ и зарегистрировать его через app.dependency_overrides. Во втором случае вы получаете полную свободу: можете подменять класс полностью, а не только возвращаемое значение. Это полезно, когда зависимость не просто "данные", а комплексный компонент со своим состоянием или жизненным циклом.
Dependency Injection не ограничивается подменой объектов для тестов. Он также помогает разделить concerns в вашем коде. Ваш endpoint становится просто маршрутизатором, который собирает готовые компоненты. Каждый компонент отвечает за свою задачу: репозиторий — за работу с базой данных, валидатор — за валидацию данных, email service — за отправку уведомлений. Такой подход делает код более модульным и понятным, а рефакторинг превращается из каторги в приятную работу.
Управление жизненным циклом зависимостей
По умолчанию Fast API создаёт зависимости заново на каждый запрос. Если одна и та же зависимость встречается в одном запросе несколько раз — например, как под-зависимость — её результат кэшируется до конца запроса, а Depends(get_db, use_cache=False) это кэширование отключает. Но кэширование внутри запроса — ещё не управление жизненным циклом приложения. Тяжёлые ресурсы, которые должны жить столько же, сколько само приложение, — пул соединений с базой, клиент внешнего API, загруженная ML-модель — создаются в lifespan-обработчике и освобождаются при остановке сервиса.
# main.py
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine
@asynccontextmanager
async def lifespan(app: FastAPI):
# Выполняется один раз при старте приложения
app.state.engine = create_async_engine(os.environ["DATABASE_URL"])
yield
# Выполняется один раз при остановке
await app.state.engine.dispose()
app = FastAPI(lifespan=lifespan)
Управление жизненным циклом критически важно для тестирования. Когда вы создаёте зависимость для теста, вы хотите иметь контроль над её состоянием и возможностью сброса между тестами. С помощью fixture и dependency_overrides вы можете полностью контролировать, какие объекты используются в тестах, и при необходимости обнулять их состояние. Это особенно важно для компонентов, которые сохраняют состояние между вызовами.
Итого у Fast API два уровня управления жизненным циклом: зависимости, создаваемые и кэшируемые в рамках одного запроса, и lifespan для ресурсов уровня приложения. Если компоненту нужен только контекст запроса — сессия базы данных, текущий пользователь — оформляйте его зависимостью. Если он дорогой и общий для всех запросов — создавайте в lifespan и доставайте через request.app.state. Разделение простое, оно описано в официальной документации и не требует дополнительных библиотек.
Сложные сценарии Dependency Injection
Dependency Injection становится особенно мощным при работе со сложными сценариями. Представьте, что вам нужно использовать разные репозитории в зависимости от контекста: например, в продакшене использовать обычные репозитории, а в тестах — фейковые, которые пишут в SQLite, а не в Postgres. Или вам нужно динамически выбирать сервис валидации в зависимости от настроек пользователя. В обоих случаях DI позволяет сделать это очень элегантно.
Fast API позволяет создавать зависимости, которые являются функциями других зависимостей. Это позволяет строить иерархии зависимостей и делегировать ответственность более низкоуровневым компонентам. Например, вы можете создать зависимость для получения текущего пользователя через JWT токен, а затем использовать эту зависимость внутри других зависимостей для получения данных репозитория пользователя. Такая вложенность делает код более организованным.
Настройка тестовой среды с pytest и uv
Перед тем как переходить к написанию тестов, нужно правильно настроить окружение для тестирования. Самый простой способ — использовать uv для управления зависимостями. Это современный менеджер пакетов, который работает намного быстрее pip и позволяет точно контролировать версии всех зависимостей вашего проекта. Установка test-зависимостей выполняется одной командой:
# Добавляем тестовые зависимости в pyproject.toml или uv.lock
uv add --group dev pytest pytest-asyncio pytest-cov httpx asgi-lifespan "testcontainers[postgres]"
Пакет pytest — это основной фреймворк для тестирования в Python. pytest-asyncio позволяет писать асинхронные тесты с помощью async def test_*. pytest-cov предоставляет отчёты о покрытии тестами кода. httpx — это HTTP-клиент для тестирования API endpoints, который работает асинхронно и эмулирует поведение реальных HTTP-запросов. testcontainers — это библиотека для запуска реальных сервисов (база данных, Redis и т.д.) в Docker контейнерах для интеграционных тестов.
Чтобы pytest-asyncio подхватывал асинхронные тесты и фикстуры без лишних ограничений, включите режим auto в настройках pytest. Отдельно стоит сказать о фикстуре event_loop, которая встречается в старых статьях: она давно устарела, а в актуальных версиях pytest-asyncio удалена — вместо неё настраиваются scope-опции плагина.
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
Библиотека pytest-testcontainers — это удобная обёртка над testcontainers для pytest, которая автоматически подключает контейнеры к fixture. Это значительно упрощает настройку: вам не нужно вручную создавать и удалять контейнеры, pytest делает это за вас. Благодаря этому интеграционные тесты становятся намного проще и надёжнее.
# Устанавливаем pytest-testcontainers
uv add --group dev pytest-testcontainers
Создаём pytest fixture для приложения
Ключевой шаг в настройке тестов — фикстура, которая возвращает готовый к запросам тестовый клиент. Приложение и его маршруты объявляются на уровне модуля, а фикстура только подменяет зависимости и настраивает их на тестовые значения. Это гарантирует, что тесты будут изолированы и не будут зависеть от реальной конфигурации приложения.
# tests/conftest.py
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from main import app
@pytest.fixture
async def client():
from database import TestDatabase, get_db
from repositories import UserRepository
# Подменяем зависимости на тестовые реализации
app.dependency_overrides[get_db] = TestDatabase.get_connection
app.dependency_overrides[UserRepository] = TestDatabase.get_user_repository
# LifespanManager запускает startup- и shutdown-логику приложения,
# которую ASGITransport сам не вызывает
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
# Очищаем переопределения после завершения теста
app.dependency_overrides.clear()
await TestDatabase.cleanup()
В этом примере фикстура создаёт AsyncClient с ASGITransport — запросы уходят напрямую в приложение, без сети. Так как транспорт не запускает lifespan-события, за это отвечает LifespanManager из пакета asgi-lifespan. get_db переопределяется на тестовую версию базы данных, которая использует SQLite для быстрых и изолированных тестов, а UserRepository — на фейковый репозиторий, работающий в памяти. После теста фикстура очищает переопределения и тестовую базу данных.
Переопределение зависимостей для unit-тестов
Для unit-тестов вам не нужно переопределять всё приложение целиком. Достаточно замокать зависимости внутри самих тестов. Для этого подходит unittest.mock.patch или monkeypatch из pytest. Это удобно, когда вы хотите переопределить конкретные компоненты для одного или нескольких тестов.
# tests/test_users.py
import pytest
from httpx import AsyncClient
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_create_user_with_validation(client: AsyncClient):
# Создаём фейковый валидатор
fake_validator = AsyncMock()
fake_validator.validate.return_value = None
# Переопределяем валидатор для этого теста
with patch("services.UserService.validate", fake_validator):
response = await client.post(
"/users",
json={"email": "test@example.com", "name": "Test User"},
)
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
@pytest.mark.asyncio
async def test_create_user_invalid_email(client: AsyncClient):
response = await client.post(
"/users",
json={"email": "invalid-email", "name": "Test User"},
)
assert response.status_code == 422
В этом примере используется unittest.mock.patch для переопределения конкретных функций. Фейковый валидатор подменяет реальную логику валидации, позволяя тестировать бизнес-логику без реальных проверок. Это типичный подход для unit-тестов: изолировать и протестировать конкретный компонент, не затрагивая другие части системы.
Интеграционные тесты с testcontainers
Интеграционные тесты — это тесты, которые проверяют, как все компоненты системы работают вместе. Для них лучше использовать реальные компоненты, а не моки. testcontainers позволяет запустить реальную базу данных (Postgres, MySQL, MongoDB), Redis или другой сервис в Docker контейнере, подключить к нему и протестировать весь стек от базы данных до API.
# tests/integration/test_users.py
import os
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from db.base import get_db
from main import app
from models import Base
# Тесты и фикстуры модуля выполняются в одном событийном цикле уровня модуля
pytestmark = pytest.mark.asyncio(loop_scope="module")
@pytest_asyncio.fixture(loop_scope="module", scope="module")
async def db_engine():
from testcontainers.postgres import PostgresContainer
# В CI база поднимается как сервис и приходит через DATABASE_URL,
# локально запускаем контейнер PostgreSQL через testcontainers
db_url = os.environ.get("DATABASE_URL")
if db_url:
engine = create_async_engine(db_url)
else:
with PostgresContainer("postgres:15", driver="asyncpg") as postgres:
engine = create_async_engine(postgres.get_connection_url())
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture(loop_scope="module")
async def client(db_engine):
TestSessionLocal = sessionmaker(bind=db_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
async with TestSessionLocal() as session:
yield session
# Переопределяем зависимость, чтобы запросы шли в тестовую базу
app.dependency_overrides[get_db] = override_get_db
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
# Очищаем переопределения
app.dependency_overrides.clear()
@pytest.mark.integration
async def test_create_user_integration(client: AsyncClient):
response = await client.post(
"/users",
json={"email": "integration@example.com", "name": "Integration Test"},
)
assert response.status_code == 200
assert response.json()["email"] == "integration@example.com"
# Проверяем, что пользователь реально сохранён в базе данных
response = await client.get(f"/users/{response.json()['id']}")
assert response.json()["email"] == "integration@example.com"
Здесь фикстура db_engine живёт на уровне модуля: база создаётся один раз и удаляется после всех тестов. Локально поднимается контейнер PostgreSQL через testcontainers, а в CI достаточно передать DATABASE_URL — и контейнер не понадобится. Все таблицы создаются из моделей SQLAlchemy, запросы идут через переопределённую зависимость get_db, а маркер loop_scope="module" удерживает тесты и фикстуры в одном событийном цикле. Это обеспечивает чистоту и изоляцию тестовой среды.
Настройка pytest для разных типов тестов
pytest позволяет легко разделять unit-тесты и интеграционные тесты с помощью маркеров. Вы можете помечать тесты специальными метками и запускать их по отдельности. Это особенно полезно в CI/CD, когда вы хотите запускать только unit-тесты на каждом коммите, а интеграционные тесты — реже, например, только при запуске отдельных пайплайнов или только для критических изменений.
# Запуск только unit-тестов (быстрые)
uv run pytest -m "not integration" --cov=. --cov-report=html
# Запуск только интеграционных тестов (медленные)
uv run pytest -m integration --cov=. --cov-report=html
# Запуск всех тестов
uv run pytest --cov=. --cov-report=html
# Запуск тестов из конкретного файла
uv run pytest tests/unit/test_users.py
# Запуск тестов с детальным выводом
uv run pytest -v --log-cli-level=INFO
Это стандартные команды для запуска тестов. Флаг -m фильтрует тесты по маркерам. Флаг --cov запускает покрытие кода тестами и создаёт отчёт в HTML формате. Вы можете добавить эти команды в Makefile, чтобы упростить запуск тестов для новых разработчиков.
# Makefile
.PHONY: test unit integration clean test-coverage
test:
uv run pytest
unit:
uv run pytest -m "not integration"
integration:
uv run pytest -m integration
test-coverage:
uv run pytest --cov=. --cov-report=html
open htmlcov/index.html
clean:
rm -rf .pytest_cache htmlcov
CI/CD конфигурация с GitLab CI
Для автоматического запуска тестов в CI/CD вы можете использовать GitLab CI или другой CI/CD сервис. GitLab CI использует конфигурационный файл .gitlab-ci.yml в корне репозитория. В этом файле вы определяете jobs (задачи), которые будут запускаться при коммитах и MR. Для проектов на FastAPI вы можете настроить джобы, которые запускают unit-тесты на каждый коммит и MR, а интеграционные — реже, только для основной ветки.
# .gitlab-ci.yml
stages:
- test
# Job для запуска unit-тестов
unit-tests:
stage: test
image: python:3.11-slim
before_script:
# Устанавливаем uv для Linux
- curl -LsSf https://astral.sh/uv/install.sh | sh
- export PATH="$HOME/.local/bin:$PATH"
# Устанавливаем зависимости
- uv sync --frozen --all-extras
script:
- uv run pytest -m "not integration" --cov=. --cov-report=html --cov-report=xml
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- htmlcov/
coverage: '/TOTAL.*? (100|\d{1,3})\%/'
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
# Job для интеграционных тестов (запускается реже)
integration-tests:
stage: test
image: python:3.11-slim
# PostgreSQL поднимается как сервис и доступен по хосту postgres
services:
- postgres:15
variables:
POSTGRES_PASSWORD: "test"
POSTGRES_USER: "test"
POSTGRES_DB: "test"
POSTGRES_HOST_AUTH_METHOD: trust
DATABASE_URL: "postgresql+asyncpg://test:test@postgres:5432/test"
before_script:
- curl -LsSf https://astral.sh/uv/install.sh | sh
- export PATH="$HOME/.local/bin:$PATH"
- uv sync --frozen --all-extras
script:
- uv run pytest -m integration --cov=. --cov-report=xml
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
coverage: '/TOTAL.*? (100|\d{1,3})\%/'
only:
- merge_requests
- main
В этом примере настроены два job: unit-tests и integration-tests. Unit-тесты запускаются для каждого коммита и MR. Интеграционные тесты используют PostgreSQL как сервис: он поднимается рядом с джобой и доступен по хосту postgres, а тесты читают DATABASE_URL. Оба job создают HTML-отчёты о покрытии и XML-отчёты для GitLab Code Coverage.
CI/CD с GitHub Actions
Если вы используете GitHub вместо GitLab, вы можете настроить аналогичный процесс с помощью GitHub Actions. GitHub Actions использует файл .github/workflows/test.yml для определения jobs и запуска тестов. Пример конфигурации для FastAPI проекта выглядит так:
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: uv sync --frozen --all-extras
- name: Run unit tests
run: uv run pytest -m "not integration" --cov=. --cov-report=html --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: unit-tests
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
POSTGRES_USER: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: uv sync --frozen --all-extras
- name: Run integration tests
env:
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test
run: uv run pytest -m integration --cov=. --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: integration-tests
GitHub Actions автоматически запускает unit-тесты для каждого коммита и MR. Интеграционные тесты используют PostgreSQL как сервис и запускаются только для изменения критических веток. Оба job создают отчёты о покрытии кода и автоматически загружают их в Codecov для визуализации и мониторинга. Это обеспечивает высокую надёжность CI/CD пайплайнов и быстрое обнаружение regressions.
Лучшая практика: изоляция тестовой среды
Одной из самых важных практик является изоляция тестовой среды. Каждые тесты должны работать в изолированном окружении, не влияющем на другие тесты и не оставляющем после себя изменений. testcontainers и fixture гарантируют это, но также важно следить за тем, чтобы после тестов не оставались временные файлы, контексты базы данных, или другие состояния. pytest автоматически удаляет fixture, но вы должны контролировать, что создаётся внутри каждого теста.
Для базы данных это особенно важно. Каждые интеграционные тесты должны начинаться с чистой базы данных. Самый простой способ — запустить TRUNCATE или DELETE для всех таблиц перед каждым тестом или использовать db.session.rollback() после каждого теста, чтобы откатить все изменения. Это гарантирует, что тесты не влияют друг на друга и не накапливают изменения в базе данных.
Для HTTP-запросов важно не использовать один и тот же HTTP-клиент для нескольких тестов, которые зависят от состояния. Лучше создавать новый client для каждого теста или использовать fixture, который создаёт clean client перед каждым тестом. Это гарантирует, что состояние из одного теста не влияет на другие тесты.
Отладка тестов и локальная разработка
pytest предоставляет множество инструментов для отладки. Флаг -s выводит stdout/stderr внутри тестов, что полезно для отладки, когда нужно увидеть промежуточные результаты. Флаг -vv даёт более детальный вывод о каждом тесте, включая вывод fixture и зависимостей. Вы можете также использовать pytest-xdist для параллельного запуска тестов на несколько потоков, что ускоряет запуск в больших проектах.
# Запуск с выводом stdout
uv run pytest -s -v
# Параллельный запуск тестов
uv run pytest -n auto
# Запуск конкретного теста
uv run pytest tests/unit/test_users.py::test_create_user
# Отладка с pdb
uv run pytest --pdb
Для отладки API-тестов можно использовать httpx или TestClient с выводом запросов и ответов. Это поможет понять, какие данные передаются в endpoint и что возвращается. Также можно использовать print или logging внутри тестов для вывода отладочной информации.
Интеграционные тесты хорошо дополняет контрактный подход: если API описан схемой заранее, проверки опираются на единый источник истины. Мы рассказывали, как проектировать API-схему до кода и держать контракт под контролем.
Заключение
Fast API Dependency Injection — это не просто архитектурная изысканность, а практически необходимый инструмент для написания тестируемого кода. Он превращает хаотичную инъекцию зависимостей в структурированный процесс. Используйте его всерьёз: тестируемость сильно возрастает, а время на написание тестов — сокращается. Когда вы можете легко подменить любую зависимость, вы начинаете писать модульный код, который не ломается от внешних изменений.
Тестируемость — это не требование, а результат правильного выбора архитектуры. Dependency Injection в Fast API помогает сделать этот выбор осознанным и обоснованным. Он позволяет писать код, который не привязан к конкретной реализации, который легко меняется, который проще расширять и который меньше ломается. Всё это делает разработку более эффективной и приятной.
Использование pytest, uv, testcontainers и CI/CD превращает процесс тестирования в стандартную часть разработки. Новые разработчики могут быстро настроить окружение и начать писать тесты благодаря простым инструкциям и auto-generated документации Fast API. Высокое покрытие тестами и автоматический запуск в CI/CD гарантируют, что ваш код будет надёжным и стабильным.
Обсудим ваш проект?
Расскажите о вашей задаче — вернёмся с оценкой сроков и стоимости в течение двух рабочих дней.