FastAPI Dependency Injection: тестируем правильно

Технологии
15 мая 2026Время чтения 8 мин

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 гарантируют, что ваш код будет надёжным и стабильным.