Когда в одном репозитории живут 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, которые иначе тихо игнорируются.
Несколько практических уроков, которые экономят дни отладки
Опыт построения такой структуры на нескольких проектах сводится к набору приёмов, которые лучше внедрять с самого начала, а не лечить потом:
- Никогда не класть инициализацию фреймворка (django.setup, Celery-конфигурацию) в корневой conftest.py. Эти штуки должны жить ровно в том каталоге, где они нужны;
- Все фикстуры, связанные с базой данных или внешними сервисами, должны полагаться на scope="session" для контейнеров и более узкий scope для самих сессий. Поднимать контейнер на каждый тест это путь к тестам, которые длятся часами;
- Прописывать pytest_plugins только в корневых conftest.py и в самых верхних точках входа. Включение плагина из глубоко вложенного conftest.py с версии pytest 7 стало явным предупреждением, а в будущем может стать ошибкой;
- Использовать pytest-asyncio с asyncio_mode = "auto" - тогда не нужно помечать каждую async-фикстуру и async-тест декоратором;
- Никогда не передавать app в фикстуры через глобальный импорт, если приложение делает что-то в момент импорта (например, подключается к базе). Лучше создавать тестовый экземпляр приложения внутри фикстуры с тестовой конфигурацией.
Монорепозиторий с pytest становится управляемым, когда структура файлов отражает изолированность сервисов, фикстуры именуются с префиксами, а корневой conftest.py не пытается знать про всё сразу. Тогда добавление нового сервиса в репозиторий перестаёт быть мучением. Достаточно создать новый каталог под services/, положить в него свой conftest.py и забыть про чужие фикстуры. Тесты остального проекта при этом продолжат работать без изменений, и ровно этого все и хотят от хорошо устроенного тестового пайплайна.