FastAPI Dependency Injection: тестируем правильно
ТехнологииService Lab.
Fast API предоставляет мощный инструмент для внедрения зависимостей (Dependency Injection), который часто недооценивают при написании тестов. В отличие от Django или Flask, Fast API позволяет легко подменять реальные зависимости на моки и фикстуры. Это делает тестирование сервисного слоя практически прозрачным: вы можете изолировать репозитории, внешние API и даже базу данных без изменения бизнес-логики.
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 — за отправку уведомлений. Такой подход делает код более модульным и понятным, а рефакторинг превращается из каторги в приятную работу.
Управление жизненным циклом зависимостей
Dependency Injection в Fast API также позволяет контролировать жизненный цикл зависимостей. По умолчанию зависимости создаются на каждый запрос. Но это не всегда нужно. Для некоторых компонентов — например, базы данных сессии или монолитного репозитория — лучше создавать один экземпляр на весь жизненный цикл приложения. Это снижает нагрузку на ресурсы и улучшает производительность. Fast API позволяет явно указать Depends(get_db, use_cache=True) или наследоваться от базового класса, чтобы управление жизненным циклом было понятным.
Управление жизненным циклом критически важно для тестирования. Когда вы создаёте зависимость для теста, вы хотите иметь контроль над её состоянием и возможностью сброса между тестами. С помощью fixture и dependency_overrides вы можете полностью контролировать, какие объекты используются в тестах, и при необходимости обнулять их состояние. Это особенно важно для компонентов, которые сохраняют состояние между вызовами.
Fast API поддерживает несколько стратегий управления жизненным циклом: singleton, scoped и transient. Singleton создаётся один раз и используется для всех запросов. Scoped создаётся в рамках одного контекста (например, одного веб-запроса или одного теста). Transient создаётся заново для каждого использования. Выбор стратегии зависит от того, нужно ли сохранять состояние между вызовами, и есть ли у компонента тяжелые ресурсы, которые экономить.
Сложные сценарии 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 testcontainers
Пакет pytest — это основной фреймворк для тестирования в Python. pytest-asyncio позволяет писать асинхронные тесты с помощью async def test_*. pytest-cov предоставляет отчёты о покрытии тестами кода. httpx — это HTTP-клиент для тестирования API endpoints, который работает асинхронно и эмулирует поведение реальных HTTP-запросов. testcontainers — это библиотека для запуска реальных сервисов (база данных, Redis и т.д.) в Docker контейнерах для интеграционных тестов.
Библиотека pytest-testcontainers — это удобный обёрток над testcontainers для pytest, который автоматически подключает контейнеры к fixture. Это значительно упрощает настройку: вам не нужно вручную создавать и удалять контейнеры, pytest делает это за вас. Благодаря этому интеграционные тесты становятся намного проще и надёжнее.
# Устанавливаем pytest-testcontainers
uv add --group dev pytest-testcontainers
Создаём pytest fixture для приложения
Ключевой шаг в настройке тестов — создать fixture, который возвращает готовое к использованию приложение. В pytest вы можете использовать @pytest.fixture для этой цели. Этот fixture должен перегружать все зависимости, которые используются в вашем приложении, и настраивать их на использование тестовых значений. Это гарантирует, что тесты будут изолированы и не будут зависеть от реальной конфигурации приложения.
# tests/conftest.py
import pytest
from httpx import AsyncClient
from main import app
# Fixture для настройки базы данных в тестах
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
# Fixture для создания AsyncClient с переопределёнными зависимостями
@pytest.fixture
async def client():
from database import get_db
from repositories import UserRepository
# Переопределяем зависимость для получения фейковой базы данных
from database import TestDatabase
app.dependency_overrides[get_db] = TestDatabase.get_connection
app.dependency_overrides[UserRepository] = TestDatabase.get_user_repository
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
# Очищаем переопределения после завершения теста
app.dependency_overrides.clear()
TestDatabase.cleanup()
В этом примере fixture создаёт AsyncClient с переопределёнными зависимостями. get_db переопределяется на тестовую версию базы данных, которая использует SQLite для быстрых и изолированных тестов. UserRepository переопределяется на фейковый репозиторий, который работает в памяти. После завершения всех тестов в сессии fixture очищает переопределения и базу данных.
Переопределение зависимостей для unit-тестов
Для unit-тестов вам не нужно переопределять всё приложение целиком. Достаточно замокать зависимости внутри самих тестов. pytest позволяет использовать monkeypatch для глобального изменения переменных и функций. Это удобно, когда вы хотите переопределить конкретные компоненты для одного или нескольких тестов.
# tests/test_users.py
import pytest
from unittest.mock import AsyncMock, patch
from repositories import UserRepository
@pytest.mark.asyncio
async def test_create_user_with_validation(client: AsyncClient, monkeypatch):
# Создаём фейковый валидатор
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
В этом примере используется monkeypatch для переопределения конкретных функций. Фейковый валидатор подменяет реальную логику валидации, позволяя тестировать бизнес-логику без реальных проверок. Это типичный подход для unit-тестов: изолировать и протестировать конкретный компонент, не затрагивая другие части системы.
Интеграционные тесты с testcontainers
Интеграционные тесты — это тесты, которые проверяют, как все компоненты системы работают вместе. Для них лучше использовать реальные компоненты, а не моки. testcontainers позволяет запустить реальную базу данных (Postgres, MySQL, MongoDB), Redis или другой сервис в Docker контейнере, подключить к нему и протестировать весь стек от базы данных до API.
# tests/integration/test_users.py
import pytest
from fastapi.testclient import TestClient
from main import app
from db.base import get_db
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker
from models import Base, User
from repositories import UserRepository
# Fixture для запуска реальной PostgreSQL базы данных
@pytest.fixture(scope="module")
def postgres_container():
from testcontainers.postgres import PostgresContainer
import os
with PostgresContainer("postgres:15") as postgres:
# Подключаемся к базе данных и создаём структуру таблиц
db_url = postgres.get_connection_url()
os.environ["DATABASE_URL"] = db_url
engine = create_async_engine(db_url, echo=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield postgres
# Удаляем таблицы после тестов
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
engine.dispose()
# Fixture для создания test client с реальной базой данных
@pytest.fixture
def client(postgres_container):
# Создаём сессию базы данных для тестов
db_url = postgres_container.get_connection_url()
engine = create_async_engine(db_url)
TestSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@app.get("/")
def read_root():
return {"message": "test"}
# Переопределяем зависимости для использования реальной базы данных
async def override_get_db():
async with TestSessionLocal() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
# Очищаем переопределения
app.dependency_overrides.clear()
@pytest.mark.integration
async def test_create_user_integration(client: TestClient):
response = client.post(
"/users",
json={"email": "integration@example.com", "name": "Integration Test"},
)
assert response.status_code == 200
assert response.json()["email"] == "integration@example.com"
# Проверяем, что пользователь реально сохранён в базе данных
response = client.get(f"/users/{response.json()['id']}")
assert response.json()["email"] == "integration@example.com"
В этом примере используется PostgresContainer для запуска реальной PostgreSQL базы данных в Docker контейнере. Контейнер создаётся один раз для всего модуля тестов и удаляется после завершения. Создаются все таблицы из моделей SQLAlchemy, затем тесты запускаются с переопределёнными зависимостями, которые используют эту базу данных. После всех тестов таблицы удаляются. Это обеспечивает чистоту и изоляцию тестовой среды.
Настройка 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 вы можете настроить runtests, который запускает unit-тесты и иногда интеграционные тесты.
# .gitlab-ci.yml
stages:
- test
variables:
# Указываем расположение uvpy
UV_PYTHON_DOWNLOAD_URL: "https://github.com/astral-sh/uv/releases/download/0.2.27/uv-0.2.27-macos-arm64-installer.sh"
UV_PYTHON_VERSION: "3.11"
# Job для запуска unit-тестов
unit-tests:
stage: test
image: python:3.11-slim
before_script:
# Устанавливаем uv
- wget -O /usr/local/bin/uv $UV_PYTHON_DOWNLOAD_URL
- chmod +x /usr/local/bin/uv
# Устанавливаем зависимости
- 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
services:
- docker:dind
variables:
POSTGRES_PASSWORD: "test"
POSTGRES_USER: "test"
POSTGRES_DB: "test"
DATABASE_URL: "postgresql+asyncpg://test:test@postgres:5432/test"
before_script:
- wget -O /usr/local/bin/uv $UV_PYTHON_DOWNLOAD_URL
- chmod +x /usr/local/bin/uv
- 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, используя Docker в Docker (dind) для запуска тестов. Интеграционные тесты запускаются только для изменения основной ветки или MR, и используют PostgreSQL из Docker Hub для тестирования. Оба 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-запросов важно не использовать один и тот же TestClient для нескольких тестов, которые зависят от состояния. Лучше создавать новый 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 внутри тестов для вывода отладочной информации.
Заключение
Fast API Dependency Injection — это не просто архитектурная изысканность, а практически необходимый инструмент для написания тестируемого кода. Он превращает хаотичную инъекцию зависимостей в структурированный процесс. Используйте его всерьёз: тестируемость сильно возрастает, а время на написание тестов — сокращается. Когда вы можете легко подменить любую зависимость, вы начинаете писать модульный код, который не ломается от внешних изменений.
Тестируемость — это не требование, а результат правильного выбора архитектуры. Dependency Injection в Fast API помогает сделать этот выбор осознанным и обоснованным. Он позволяет писать код, который не привязан к конкретной реализации, который легко меняется, который проще расширять и который меньше ломается. Всё это делает разработку более эффективной и приятной.
Использование pytest, uv, testcontainers и CI/CD превращает процесс тестирования в стандартную часть разработки. Новые разработчики могут быстро настроить окружение и начать писать тесты благодаря простым инструкциям и auto-generated документации Fast API. Высокое покрытие тестами и автоматический запуск в CI/CD гарантируют, что ваш код будет надёжным и стабильным.


