Когда в одном репозитории живут FastAPI-сервис, Django-админка и пачка Celery-воркеров, написать общие тесты становится отдельной инженерной задачей. Каждая из этих штук тащит свой набор фикстур, свой подход к базе данных, свой жизненный цикл. Django хочет видеть DJANGO_SETTINGS_MODULE и поднимать тестовую базу через свой механизм. FastAPI ждёт ASGI-клиента и асинхронных сессий. Celery просит решить, гонять задачи в eager-режиме или поднимать настоящий воркер. И всё это надо собрать так, чтобы pytest не запутался, фикстуры не конфликтовали по именам, а сами тесты получались короткими и понятными. Разбор того, как организовать conftest.py-файлы в такой структуре, и есть тема этой статьи.

Почему стандартный совет "все фикстуры в один conftest.py" перестаёт работать на масштабе

В документации pytest сказано просто - кладите общие фикстуры в conftest.py в корне tests/, и они автоматически подтянутся для всех тестов. Совет золотой для маленького проекта. На монорепозитории он ломается на первом же конфликте имён. Что значит фикстура db_session? Для FastAPI это асинхронная сессия SQLAlchemy. Для Django это совершенно другой механизм, привязанный к pytest-django. Для Celery-задач, которые ходят в ту же базу, это может быть третий контекст. Свести всё в один файл нельзя без переименований, а переименования портят читаемость тестов.

Вторая проблема - порядок инициализации. Django хочет, чтобы settings.configure() или DJANGO_SETTINGS_MODULE были выставлены до импорта моделей. FastAPI же часто стартует совсем без Django и должен работать в чистом окружении. Если положить django.setup() в корневой conftest.py, тесты FastAPI-сервиса начнут поднимать ненужный Django и есть лишние секунды на каждом прогоне.

Третья - область видимости. Корневой conftest.py доступен для всех тестов автоматически. Но если в одном из сервисов используется конкретная база, конкретный клиент, конкретные мок-объекты, они должны быть видны только в тестах этого сервиса. Иначе фикстура для FastAPI случайно подтянется в Django-тест, и кто-то полчаса будет смотреть на странную ошибку про несовместимые сессии.

Структура каталогов, которая держит порядок

Реалистичная раскладка монорепозитория с тремя сервисами выглядит примерно так. Структура неидеальна, но она проверена временем и хорошо ложится на pytest:

monorepo/
├── pyproject.toml
├── pytest.ini
├── services/
│   ├── api/
│   │   ├── src/
│   │   │   └── api_app/
│   │   │       ├── __init__.py
│   │   │       ├── main.py
│   │   │       └── routers/
│   │   └── tests/
│   │       ├── conftest.py
│   │       ├── test_users.py
│   │       └── test_orders.py
│   ├── admin/
│   │   ├── src/
│   │   │   └── admin_app/
│   │   │       ├── settings.py
│   │   │       ├── manage.py
│   │   │       └── core/
│   │   └── tests/
│   │       ├── conftest.py
│   │       ├── test_views.py
│   │       └── test_models.py
│   └── worker/
│       ├── src/
│       │   └── worker_app/
│       │       ├── __init__.py
│       │       ├── celery_app.py
│       │       └── tasks.py
│       └── tests/
│           ├── conftest.py
│           ├── test_tasks.py
│           └── test_pipelines.py
├── shared/
│   ├── src/
│   │   └── shared_lib/
│   │       ├── database.py
│   │       ├── models.py
│   │       └── schemas.py
│   └── tests/
│       ├── conftest.py
│       └── test_database.py
├── tests/
│   ├── conftest.py
│   ├── fixtures/
│   │   ├── __init__.py
│   │   ├── database.py
│   │   ├── factories.py
│   │   └── containers.py
│   └── integration/
│       ├── conftest.py
│       └── test_end_to_end.py
└── tests_plugins/
    └── shared_fixtures.py

Здесь работают три уровня conftest.py-файлов. Корневой tests/conftest.py содержит универсальные фикстуры, которые могут понадобиться кому угодно (фабрики данных, контейнеры с базой, общие моки внешних API). Конкретные сервисные conftest.py живут в services/{name}/tests/ и держат специфику этого сервиса. Интеграционные тесты, которые проверяют связку всех трёх компонентов, лежат отдельно в tests/integration/ со своим conftest.py.

Корневой conftest.py с общими фикстурами

Самое важное в корневом файле - то, чего в нём не должно быть. Не должно быть инициализации Django, не должно быть импорта FastAPI-приложения, не должно быть Celery-конфигурации. Всё это специфично для конкретных сервисов и должно жить ниже. В корне разумно держать только то, что не привязано к фреймворку:

import pytest
from pathlib import Path
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer


@pytest.fixture(scope="session")
def project_root() -> Path:
    return Path(__file__).parent.parent


@pytest.fixture(scope="session")
def postgres_container():
    with PostgresContainer("postgres:16-alpine") as container:
        yield container


@pytest.fixture(scope="session")
def redis_container():
    with RedisContainer("redis:7-alpine") as container:
        yield container


@pytest.fixture(scope="session")
def postgres_url(postgres_container) -> str:
    return postgres_container.get_connection_url()


@pytest.fixture(scope="session")
def redis_url(redis_container) -> str:
    host = redis_container.get_container_host_ip()
    port = redis_container.get_exposed_port(6379)
    return f"redis://{host}:{port}/0"

Контейнеры с базой и Redis поднимаются один раз на всю сессию тестов. Каждый сервис потом возьмёт URL из этих фикстур и сконфигурирует свой клиент. Это даёт ровно тот эффект, который нужен - реальная база, реальный Redis, общие для всей сессии, но изолированные от продакшена через testcontainers.

Дополнительные фикстуры, общие для нескольких сервисов, выносятся в подкаталог tests/fixtures/. Это уже не conftest.py, а обычные модули, которые подключаются через pytest_plugins:

# tests/conftest.py (продолжение)

pytest_plugins = [
    "tests.fixtures.database",
    "tests.fixtures.factories",
    "tests.fixtures.containers",
]

Каждый модуль из этого списка ведёт себя как полноценный плагин pytest - все его фикстуры становятся доступны во всех тестах. Это аккуратнее, чем один разросшийся conftest.py на тысячу строк.

Conftest.py для FastAPI-сервиса

В services/api/tests/conftest.py живут вещи, относящиеся только к API-сервису. Это асинхронные клиенты httpx, override-зависимости, фикстуры для аутентификации:

import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.pool import NullPool

from api_app.main import app
from api_app.dependencies import get_db
from shared_lib.models import Base


@pytest_asyncio.fixture(scope="session")
async def api_engine(postgres_url):
    async_url = postgres_url.replace("postgresql://", "postgresql+asyncpg://")
    engine = create_async_engine(async_url, poolclass=NullPool)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture
async def api_session(api_engine) -> AsyncSession:
    session_maker = async_sessionmaker(api_engine, expire_on_commit=False)
    async with session_maker() as session:
        yield session
        await session.rollback()


@pytest_asyncio.fixture
async def api_client(api_session) -> AsyncClient:
    async def override_get_db():
        yield api_session

    app.dependency_overrides[get_db] = override_get_db
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client
    app.dependency_overrides.clear()


@pytest.fixture
def auth_headers() -> dict:
    return {"Authorization": "Bearer test-token-fixed-for-tests"}

Тесты в services/api/tests/ получают все эти фикстуры автоматически. Они же получают и фикстуры из корневого conftest.py (postgres_url, redis_url и так далее), потому что pytest идёт по цепочке вверх и собирает все доступные conftest.py-файлы. Простой тест выглядит понятно:

async def test_create_user(api_client, auth_headers):
    response = await api_client.post(
        "/users",
        json={"email": "[email protected]", "name": "Test"},
        headers=auth_headers,
    )
    assert response.status_code == 201
    assert response.json()["email"] == "[email protected]"

Conftest.py для Django-сервиса

С Django сложнее. Тут нужен pytest-django, который требует выставленный DJANGO_SETTINGS_MODULE. Но выставлять его глобально нельзя - FastAPI-тесты не должны знать о существовании Django. Решение - локальный pytest.ini или addopts в pyproject.toml для каждого сервиса, либо точечная настройка прямо в conftest.py:

import os
import django
import pytest
from django.test import Client


def pytest_configure(config):
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "admin_app.settings.test")
    django.setup()


@pytest.fixture(scope="session")
def django_db_setup(django_db_setup, django_db_blocker, postgres_url):
    """Перенаправляем pytest-django на наш контейнер postgres."""
    from django.conf import settings

    settings.DATABASES["default"] = {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "test_admin",
        "USER": "test",
        "PASSWORD": "test",
        "HOST": "localhost",
        "PORT": 5432,
    }
    with django_db_blocker.unblock():
        yield


@pytest.fixture
def admin_client(db) -> Client:
    from django.contrib.auth import get_user_model

    User = get_user_model()
    user = User.objects.create_superuser(
        username="admin",
        email="[email protected]",
        password="testpass",
    )
    client = Client()
    client.force_login(user)
    return client

Хук pytest_configure запускается до сбора тестов и настраивает Django ровно тогда, когда pytest заходит в эту директорию. Тесты Django живут в своей вселенной, не пересекаясь с FastAPI:

import pytest


@pytest.mark.django_db
def test_admin_can_see_users(admin_client):
    response = admin_client.get("/admin/auth/user/")
    assert response.status_code == 200


@pytest.mark.django_db
def test_create_product_via_orm():
    from admin_app.core.models import Product

    product = Product.objects.create(name="Widget", price=99.99)
    assert product.pk is not None
    assert Product.objects.count() == 1

Маркер @pytest.mark.django_db включает работу с базой именно для этого теста, что аккуратнее, чем делать это глобально. Если нужен доступ к базе на все тесты модуля, ставится pytestmark = pytest.mark.django_db в начале файла.

Conftest.py для Celery-воркера

Celery в тестах разделяет два мира. Первый - юнит-тесты задач, в которых сама задача вызывается напрямую как функция, без брокера и воркера. Второй - интеграционные, где поднимается настоящий процесс воркера и задача отправляется через очередь. Для большинства случаев хватает первого:

import pytest
from celery import Celery

from worker_app.celery_app import celery_app as production_celery_app
from worker_app import tasks


@pytest.fixture(scope="session")
def celery_app(redis_url) -> Celery:
    production_celery_app.conf.update(
        broker_url=redis_url,
        result_backend=redis_url,
        task_always_eager=True,
        task_eager_propagates=True,
        task_store_eager_result=True,
    )
    return production_celery_app


@pytest.fixture(autouse=True)
def _configure_celery(celery_app):
    """Гарантия, что каждый тест видит eager-конфигурацию."""
    yield


@pytest.fixture
def process_order_task():
    return tasks.process_order

Параметр task_always_eager=True заставляет задачу выполняться синхронно прямо в том же процессе при вызове .delay() или .apply_async(). task_eager_propagates=True прокидывает исключения наружу, иначе ошибки в задачах будут молча проглатываться. task_store_eager_result=True добавляет результат в backend, что позволяет проверять успешность через .get().

Документация Celery предупреждает - eager-режим эмулирует работу воркера приблизительно, и для реальной проверки прохождения задачи через брокер он не годится. Для таких случаев есть отдельная фикстура с настоящим воркером, поднимающимся в отдельном потоке:

from celery.contrib.testing.worker import start_worker


@pytest.fixture(scope="module")
def celery_worker_real(celery_app):
    celery_app.conf.task_always_eager = False
    with start_worker(celery_app, perform_ping_check=False, shutdown_timeout=10) as worker:
        yield worker
    celery_app.conf.task_always_eager = True

Тест с настоящим воркером выглядит так:

def test_task_runs_through_broker(celery_worker_real):
    result = tasks.process_order.delay(order_id=42)
    assert result.get(timeout=5) == {"order_id": 42, "status": "processed"}

Использовать такие тесты надо умеренно - они на порядок медленнее eager-режима. Обычно хватает двух-трёх проверок сериализации и маршрутизации, всё остальное прогоняется в eager-режиме.

Изоляция фикстур и предотвращение конфликтов имён

Главная опасность в монорепозитории - случайное пересечение имён фикстур. Если в services/api/tests/conftest.py есть db_session, а в services/admin/tests/conftest.py - тоже db_session, но с другим смыслом, всё работает только потому, что они не видят друг друга. Стоит положить такую фикстуру в корневой conftest.py - и начнётся хаос.

Простое правило, которое снимает большинство проблем - давать фикстурам префикс имени сервиса. api_session, api_client, admin_client, worker_celery_app. Это удлиняет имена, но делает невозможной случайную подмену. В тестах при таком именовании сразу видно, что именно используется:

async def test_integration_flow(api_client, admin_client, celery_app, postgres_url):
    """Интеграционный тест, использующий все три сервиса."""
    response = await api_client.post("/orders", json={"product_id": 1})
    order_id = response.json()["id"]

    admin_response = admin_client.get(f"/admin/orders/order/{order_id}/")
    assert admin_response.status_code == 200

    from worker_app.tasks import finalize_order
    result = finalize_order.delay(order_id=order_id)
    assert result.get(timeout=5)["status"] == "finalized"

Этот же тест жил бы в tests/integration/, и в его conftest.py были бы подтянуты сразу все три набора фикстур через pytest_plugins.

Когда стоит выносить фикстуры в отдельный плагин

Если по мере роста проекта одни и те же фикстуры начинают переиспользоваться в новых сервисах, имеет смысл вынести их в полноценный плагин pytest. Это особенно полезно, когда монорепозиторий настолько большой, что в нём появляются несколько FastAPI-сервисов с похожей структурой. Плагин это просто Python-пакет с фикстурами и hook-функциями, который ставится в окружение через uv add или pip install -e:

# tests_plugins/shared_fixtures.py

import pytest
from typing import AsyncGenerator
from httpx import AsyncClient, ASGITransport


@pytest.fixture
async def make_api_client():
    """Фабрика клиентов для произвольного FastAPI-приложения."""
    async def _make_client(app, base_url: str = "http://test") -> AsyncClient:
        transport = ASGITransport(app=app)
        return AsyncClient(transport=transport, base_url=base_url)

    return _make_client

После регистрации такого плагина в pyproject.toml через entry_points он становится глобально доступен:

[project.entry-points."pytest11"]
shared_fixtures = "tests_plugins.shared_fixtures"

Теперь любой conftest.py в любом сервисе может опираться на make_api_client без дублирования кода. Подход избыточен для маленьких проектов, но на десятке сервисов он начинает экономить серьёзное количество строк.

Запуск тестов по сервисам и целиком

Pytest при правильной структуре умеет запускать тесты выборочно. Прогон только FastAPI-тестов:

pytest services/api/tests

Прогон тестов всего монорепозитория:

pytest

Запуск с маркерами, чтобы прогнать только быстрые юнит-тесты, минуя интеграцию:

pytest -m "not slow and not integration"

В pytest.ini или pyproject.toml имеет смысл прописать markers и testpaths, чтобы CI и локальные прогоны давали одинаковый результат:

[tool.pytest.ini_options]
testpaths = ["services", "shared/tests", "tests"]
markers = [
    "slow: тесты, занимающие больше 1 секунды",
    "integration: интеграционные тесты, поднимающие настоящие сервисы",
    "db: тесты, требующие живую базу данных",
]
addopts = "--strict-markers -ra"
asyncio_mode = "auto"

Флаг --strict-markers заставляет pytest ругаться на использование необъявленных маркеров. Это предотвращает опечатки вроде @pytest.mark.slowww, которые иначе тихо игнорируются.

Несколько практических уроков, которые экономят дни отладки

Опыт построения такой структуры на нескольких проектах сводится к набору приёмов, которые лучше внедрять с самого начала, а не лечить потом:

  1. Никогда не класть инициализацию фреймворка (django.setup, Celery-конфигурацию) в корневой conftest.py. Эти штуки должны жить ровно в том каталоге, где они нужны;
  2. Все фикстуры, связанные с базой данных или внешними сервисами, должны полагаться на scope="session" для контейнеров и более узкий scope для самих сессий. Поднимать контейнер на каждый тест это путь к тестам, которые длятся часами;
  3. Прописывать pytest_plugins только в корневых conftest.py и в самых верхних точках входа. Включение плагина из глубоко вложенного conftest.py с версии pytest 7 стало явным предупреждением, а в будущем может стать ошибкой;
  4. Использовать pytest-asyncio с asyncio_mode = "auto" - тогда не нужно помечать каждую async-фикстуру и async-тест декоратором;
  5. Никогда не передавать app в фикстуры через глобальный импорт, если приложение делает что-то в момент импорта (например, подключается к базе). Лучше создавать тестовый экземпляр приложения внутри фикстуры с тестовой конфигурацией.

Монорепозиторий с pytest становится управляемым, когда структура файлов отражает изолированность сервисов, фикстуры именуются с префиксами, а корневой conftest.py не пытается знать про всё сразу. Тогда добавление нового сервиса в репозиторий перестаёт быть мучением. Достаточно создать новый каталог под services/, положить в него свой conftest.py и забыть про чужие фикстуры. Тесты остального проекта при этом продолжат работать без изменений, и ровно этого все и хотят от хорошо устроенного тестового пайплайна.