Опирается на правила:
PYTS-4…PYTS-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"vsscope="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_container | session | Поднять Postgres = 5–10 сек; один раз на прогон |
engine | session | Привязан к контейнеру |
session_factory | session | Привязан к engine |
client | function | Overrides могут меняться между тестами |
DatabasePreparer | function | Очищает таблицы перед каждым тестом |
@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-module | PYTS-5 | session-scoped фикстура |
DSN строкой в тесте ("postgresql://localhost:5432/...") | PYTS-5 | фикстура db_url |
datetime.now() / uuid4() в бизнес-коде без DI | PYTS-7 | get_clock / get_id_generator как Depends |
Фейковые JWT-строки ("Bearer eyJ...") в тесте | PYTS-8 | success_token() / customer_token() |
Base.metadata.create_all() между тестами | PYTS-X3 | миграции один раз при старте |
Все фикстуры в одном conftest.py без структуры | PYTS-4 | платформенный + доменные раздельно |
asyncio.sleep() в фикстуре ради «дождаться контейнера» | PYTS-X1 | PostgresContainer готов после __enter__; wait_for_logs() при необходимости |
@pytest.mark.asyncio на каждом тесте | PYTS-6 | asyncio_mode = "auto" в pyproject.toml |
Куда дальше
- python/basics — базовые правила: ASGI-клиент, детерминизм, AAA.
- python/database-preparer — fluent setup БД:
clear*(),create*(),prepare(). - python/one-test — структура одного теста, имена,
success_token()в Act. - Тестирование FastAPI — хаб раздела и нормативные правила.