Опирается на правила: PYTS-4PYTS-8 из Python Test Strategy → раздел 2. Фикстуры (базовый слой).

Важно знать

  • Один conftest.py на сервис — платформенные фикстуры (контейнер, engine, клиент); доменные фикстуры — в отдельных модулях на каждый Bounded Context.
  • PostgresContainer — session-scoped: поднимается один раз за прогон, DSN прокидывается через dependency_overrides или env, не хардкодом в коде.
  • asyncio_mode = "auto" в pytest.ini / pyproject.toml — все async-тесты работают без декоратора; дорогой setup (контейнер, схема) — scope="session".
  • Время и UUID фиксированы через app.dependency_overrides[get_clock] и [get_id_generator] на фейки с предзаданными значениями; ни один вызов datetime.now() / uuid4() в бизнес-коде не должен обходить DI.
  • Авторизация — override get_principal на фейковый объект плюс хелпер success_token(); токены руками в тестах запрещены.
  • httpx.AsyncClient(transport=ASGITransport(app)) — клиент не открывает TCP-сокет, работает in-process через ASGI-интерфейс FastAPI.
  • Схема применяется один раз при старте (Liquibase / Alembic); между тестами — только DELETE/TRUNCATE нужных таблиц, не пересоздание.
  • scope="session" vs scope="function": контейнер и engine — session; DatabasePreparer и его очистка — function.

Структура фикстур

PYTS-4: один платформенный набор + доменные DatabasePreparer-ы.

tests/
  conftest.py                     # PostgresContainer, engine, client, clock/uuid overrides
  fixtures/
    auth.py                       # success_token(), customer_token(), fakes для get_principal
    clock.py                      # FakeClock, FakeIdGenerator
  order/
    conftest.py                   # OrderDatabasePreparer фикстура
    test_confirm_order.py
  product/
    conftest.py                   # ProductDatabasePreparer фикстура
    test_create_product.py

Платформенный conftest.py держит всё, что нужно любому тесту: контейнер, async engine, ASGI-клиент, override времени и UUID. Доменные conftest.py добавляют только свой DatabasePreparer.

PostgresContainer — session-scoped

PYTS-5: контейнер поднимается один раз, DSN через dependency_overrides.

# tests/conftest.py
import pytest
import pytest_asyncio
from testcontainers.postgres import PostgresContainer
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from httpx import AsyncClient, ASGITransport

from app.main import app
from app.infrastructure.db.session import get_async_session
from app.config import Settings, get_settings


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


@pytest.fixture(scope="session")
def db_url(postgres_container: PostgresContainer) -> str:
    return postgres_container.get_connection_url().replace(
        "postgresql+psycopg2", "postgresql+asyncpg"
    )


@pytest_asyncio.fixture(scope="session")
async def engine(db_url: str):
    engine = create_async_engine(db_url, echo=False)
    await apply_migrations(db_url)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture(scope="session")
async def session_factory(engine):
    return async_sessionmaker(engine, expire_on_commit=False)

DSN никогда не хардкодится строкой в тесте — он живёт в фикстуре и прокидывается через dependency_overrides в get_settings или get_async_session.

pytest-asyncio и scope

PYTS-6: asyncio_mode = "auto" в pyproject.toml.

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"

Это освобождает от @pytest.mark.asyncio на каждом тесте. Scope подбирается по цене:

ФикстураScopeПочему
postgres_containersessionПоднять Postgres = 5–10 сек; один раз на прогон
enginesessionПривязан к контейнеру
session_factorysessionПривязан к engine
clientfunctionOverrides могут меняться между тестами
DatabasePreparerfunctionОчищает таблицы перед каждым тестом
@pytest_asyncio.fixture(scope="function")
async def client(session_factory, fake_clock, fake_id_generator):
    test_settings = Settings(database_url=str(session_factory.kw["bind"].url))

    app.dependency_overrides[get_settings] = lambda: test_settings
    app.dependency_overrides[get_clock] = lambda: fake_clock
    app.dependency_overrides[get_id_generator] = lambda: fake_id_generator

    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        yield ac

    app.dependency_overrides.clear()

Детерминированное время и UUID

PYTS-7: app.dependency_overrides[get_clock] и [get_id_generator] на фейки.

# tests/fixtures/clock.py
from datetime import datetime, timezone
from uuid import UUID
from app.domain.ports.clock import Clock
from app.domain.ports.id_generator import IdGenerator


class FakeClock(Clock):
    def __init__(self, fixed: datetime):
        self._fixed = fixed

    def now(self) -> datetime:
        return self._fixed


class FakeIdGenerator(IdGenerator):
    def __init__(self, ids: list[UUID]):
        self._queue = list(ids)

    def generate(self) -> UUID:
        return self._queue.pop(0)
# tests/conftest.py
from datetime import datetime, timezone
from uuid import UUID
from tests.fixtures.clock import FakeClock, FakeIdGenerator

ORDER_TIME = datetime(2026, 5, 26, 10, 0, 0, tzinfo=timezone.utc)
ORDER_ID = UUID("11111111-1111-1111-1111-111111111111")


@pytest.fixture
def fake_clock() -> FakeClock:
    return FakeClock(fixed=ORDER_TIME)


@pytest.fixture
def fake_id_generator() -> FakeIdGenerator:
    return FakeIdGenerator(ids=[ORDER_ID])

Тест получает предсказуемые значения и может сравнивать их в Assert без дрожания:

async def test_create_order_when_valid_returns_201(client, fake_id_generator):
    fake_id_generator._queue = [UUID("aaaaaaaa-0000-0000-0000-000000000001")]

    response = await client.post("/v1/orders", json={"customer_id": "cust-1"})

    assert response.status_code == 201
    assert response.json()["order_id"] == "aaaaaaaa-0000-0000-0000-000000000001"

Авторизация через override

PYTS-8: app.dependency_overrides[get_principal] на фейк.

# tests/fixtures/auth.py
from dataclasses import dataclass
from app.domain.model.principal import Principal
from app.infrastructure.auth.dependencies import get_principal


@dataclass
class FakePrincipal:
    sub: str
    roles: list[str]


def success_token(sub: str = "user-1", roles: list[str] | None = None) -> FakePrincipal:
    return FakePrincipal(sub=sub, roles=roles or ["customer"])


def customer_token(customer_id: str) -> FakePrincipal:
    return FakePrincipal(sub=customer_id, roles=["customer"])


def admin_token() -> FakePrincipal:
    return FakePrincipal(sub="admin-1", roles=["admin"])

В фикстуре client override прокидывается заранее:

from tests.fixtures.auth import success_token

@pytest_asyncio.fixture
async def client(session_factory, fake_clock, fake_id_generator):
    app.dependency_overrides[get_principal] = lambda: success_token()
    # ... остальные overrides ...
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        yield ac
    app.dependency_overrides.clear()

Если тест проверяет разграничение ролей — override меняется прямо в тесте:

async def test_cancel_order_when_not_admin_returns_403(client, app):
    app.dependency_overrides[get_principal] = lambda: customer_token("cust-42")

    response = await client.delete("/v1/orders/some-id")

    assert response.status_code == 403

JWT-строки в тестах запрещены: если завтра изменится структура токена — один хелпер, не десятки мест.

Доменная фикстура DatabasePreparer

PYTS-4 + PYTS-10: доменный preparer как pytest-фикстура.

# tests/order/conftest.py
import pytest_asyncio
from tests.order.order_database_preparer import OrderDatabasePreparer


@pytest_asyncio.fixture
async def order_preparer(session_factory) -> OrderDatabasePreparer:
    async with session_factory() as session:
        preparer = OrderDatabasePreparer(session)
        await preparer.clear_all()
        yield preparer
        await session.rollback()

Сам OrderDatabasePreparer — в разделе python/database-preparer.

Типичный conftest.py целиком

# tests/conftest.py
import pytest
import pytest_asyncio
from datetime import datetime, timezone
from uuid import UUID

from testcontainers.postgres import PostgresContainer
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from httpx import AsyncClient, ASGITransport

from app.main import app
from app.config import Settings, get_settings
from app.domain.ports.clock import get_clock
from app.domain.ports.id_generator import get_id_generator
from app.infrastructure.auth.dependencies import get_principal
from tests.fixtures.clock import FakeClock, FakeIdGenerator
from tests.fixtures.auth import success_token
from tests.helpers.migrations import apply_migrations

FIXED_TIME = datetime(2026, 5, 26, 10, 0, 0, tzinfo=timezone.utc)
FIXED_UUID = UUID("11111111-1111-1111-1111-111111111111")


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


@pytest.fixture(scope="session")
def db_url(postgres_container):
    return postgres_container.get_connection_url().replace(
        "postgresql+psycopg2", "postgresql+asyncpg"
    )


@pytest_asyncio.fixture(scope="session")
async def engine(db_url):
    e = create_async_engine(db_url)
    await apply_migrations(db_url)
    yield e
    await e.dispose()


@pytest_asyncio.fixture(scope="session")
async def session_factory(engine):
    return async_sessionmaker(engine, expire_on_commit=False)


@pytest.fixture
def fake_clock():
    return FakeClock(fixed=FIXED_TIME)


@pytest.fixture
def fake_id_generator():
    return FakeIdGenerator(ids=[FIXED_UUID])


@pytest_asyncio.fixture
async def client(session_factory, fake_clock, fake_id_generator):
    test_settings = Settings(database_url=str(session_factory.kw["bind"].url))
    app.dependency_overrides[get_settings] = lambda: test_settings
    app.dependency_overrides[get_clock] = lambda: fake_clock
    app.dependency_overrides[get_id_generator] = lambda: fake_id_generator
    app.dependency_overrides[get_principal] = lambda: success_token()

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        yield ac

    app.dependency_overrides.clear()

Что запрещено

АнтипаттернПравилоЧто взамен
PostgresContainer per-test или per-modulePYTS-5session-scoped фикстура
DSN строкой в тесте ("postgresql://localhost:5432/...")PYTS-5фикстура db_url
datetime.now() / uuid4() в бизнес-коде без DIPYTS-7get_clock / get_id_generator как Depends
Фейковые JWT-строки ("Bearer eyJ...") в тестеPYTS-8success_token() / customer_token()
Base.metadata.create_all() между тестамиPYTS-X3миграции один раз при старте
Все фикстуры в одном conftest.py без структурыPYTS-4платформенный + доменные раздельно
asyncio.sleep() в фикстуре ради «дождаться контейнера»PYTS-X1PostgresContainer готов после __enter__; wait_for_logs() при необходимости
@pytest.mark.asyncio на каждом тестеPYTS-6asyncio_mode = "auto" в pyproject.toml

Куда дальше

  • python/basics — базовые правила: ASGI-клиент, детерминизм, AAA.
  • python/database-preparer — fluent setup БД: clear*(), create*(), prepare().
  • python/one-test — структура одного теста, имена, success_token() в Act.
  • Тестирование FastAPI — хаб раздела и нормативные правила.