Опирается на правила: PYTS-12, PYTS-13, PYTS-14 из Python Test Strategy → раздел 4. ObjectGenerator — fluent builders.

Важно знать

  • На каждую таблицу — отдельный <Domain>ObjectGenerator с методами with_*() и build()/generate().
  • Разумные дефолтыuuid4(), datetime(..., tzinfo=UTC) с фиксированными значениями; в тесте перезаписываем только то, что важно для сценария.
  • build()/generate() всегда возвращает новый объект — один экземпляр generator-а можно вызвать несколько раз.
  • Сравнение datetime с БД — PostgreSQL timestamptz хранит микросекунды; Python datetime.now() может содержать субмикросекундный хвост (зависит от платформы); усекать через .replace(microsecond=...) или truncate_to_microseconds().
  • Generator — обычный Python-класс, без зависимости от FastAPI, SQLAlchemy и pytest. Создаётся через OrderObjectGenerator().
  • Без generator-а каждый тест дублирует 10–15 полей dict/dataclass; важные для сценария поля тонут в шуме.
  • Используется в Arrange-фазе совместно с DatabasePreparer: generator строит объект, препарер вставляет его в БД.

OrderObjectGenerator, CustomerObjectGenerator, ProductObjectGenerator — builder-ы тестовых данных. Без них Arrange-фаза каждого теста содержит длинный инициализирующий блок: важное поле (status, customer_id) не видно среди boilerplate. Generator оставляет видным только тот аспект, который проверяет конкретный тест.

Структура generator-а

PYTS-12: на каждую таблицу/сущность — builder с with_*()-методами и build()/generate().

from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID, uuid4

UTC = timezone.utc

@dataclass
class OrderRow:
    id: UUID
    customer_id: UUID
    status: str
    total_amount: Decimal
    created_at: datetime
    updated_at: datetime


class OrderObjectGenerator:
    def __init__(self) -> None:
        self._id: UUID = uuid4()
        self._customer_id: UUID = uuid4()
        self._status: str = "DRAFT"
        self._total_amount: Decimal = Decimal("100.00")
        self._created_at: datetime = datetime(2026, 1, 15, 10, 0, 0, tzinfo=UTC)
        self._updated_at: datetime = datetime(2026, 1, 15, 10, 0, 0, tzinfo=UTC)

    def with_id(self, id: UUID) -> "OrderObjectGenerator":
        self._id = id
        return self

    def with_customer_id(self, customer_id: UUID) -> "OrderObjectGenerator":
        self._customer_id = customer_id
        return self

    def with_status(self, status: str) -> "OrderObjectGenerator":
        self._status = status
        return self

    def with_total_amount(self, amount: Decimal) -> "OrderObjectGenerator":
        self._total_amount = amount
        return self

    def with_created_at(self, created_at: datetime) -> "OrderObjectGenerator":
        self._created_at = created_at
        return self

    def build(self) -> OrderRow:
        return OrderRow(
            id=self._id,
            customer_id=self._customer_id,
            status=self._status,
            total_amount=self._total_amount,
            created_at=self._created_at,
            updated_at=self._updated_at,
        )

    generate = build

Что важно:

  • Обычный Python-класс, без @pytest.fixture, без FastAPI DI. Создаётся через OrderObjectGenerator().
  • Поля с дефолтами в __init__; with_*() возвращает self — fluent цепочка.
  • build() / generate() — синонимы; build() следует биндингу, generate() — Java-конвенции. Выберите одно в команде и придерживайтесь.
  • Каждый вызов build() возвращает новый OrderRow — нет проблемы общего состояния.

Разумные дефолты

PYTS-13: в тесте перезаписываем только поля, важные для сценария.

async def test_confirm_order_when_draft_returns_200(
    client: AsyncClient,
    db_preparer: OrderDatabasePreparer,
) -> None:
    order_id = UUID("11111111-1111-1111-1111-111111111111")

    order = (
        OrderObjectGenerator()
        .with_id(order_id)
        .with_status("DRAFT")
        .build()
    )
    await db_preparer.create_order(order).prepare()

    response = await client.post(
        f"/v1/orders/{order_id}/confirm",
        headers=success_token(),
    )

    assert response.status_code == 200

customer_id, total_amount, created_at — не указаны, берутся дефолты. Тест читается: «есть заказ в статусе DRAFT с известным id». Всё остальное — шум, который generator скрывает.

Сравнение с вариантом без generator-а:

# без generator-а — 10+ строк шума в каждом тесте
order = {
    "id": order_id,
    "customer_id": uuid4(),
    "status": "DRAFT",
    "total_amount": Decimal("100.00"),
    "created_at": datetime(2026, 1, 15, 10, 0, 0, tzinfo=UTC),
    "updated_at": datetime(2026, 1, 15, 10, 0, 0, tzinfo=UTC),
}
await session.execute(
    text(
        "INSERT INTO orders (id, customer_id, status, total_amount, created_at, updated_at)"
        " VALUES (:id, :customer_id, :status, :total_amount, :created_at, :updated_at)"
    ),
    order,
)

Generator убирает десять строк вставки, оставляя два поля, которые меняют поведение системы.

Фиксированное время в дефолтах

PYTS-13 / PYTS-14: дефолтное время в generator-е — фиксированный UTC-литерал, не datetime.now().

# Хорошо — одинаковое значение при каждом вызове build()
self._created_at: datetime = datetime(2026, 1, 15, 10, 0, 0, tzinfo=UTC)

# Плохо — разные значения при разных вызовах build(); при параллельных прогонах
# тест может упасть из-за расхождения в миллисекундах
self._created_at: datetime = datetime.now(tz=UTC)

Когда тест явно задаёт время через dependency_overrides на get_clock — используется это значение, не дефолт generator-а. Дефолт нужен для остальных тестов, где created_at не имеет значения для сценария.

Сравнение datetime с PostgreSQL

PYTS-14: PostgreSQL timestamptz хранит микросекунды (precision 6). Python datetime может иметь субмикросекундные значения в поле microsecond, хотя на практике это редко. Безопаснее усекать при сравнении.

Проблема без усечения:

Вставляем:  2026-01-15 10:00:00.123456789  ← datetime с наносекундами (Python 3.12+)
В PG:       2026-01-15 10:00:00.123456+00   ← PG усекает до мкс
Читаем:     2026-01-15 10:00:00.123456+00   ← asyncpg/psycopg вернут datetime

Если assertion expected == actual — может дать ложное расхождение на субмикросекундном хвосте.

Безопасное сравнение — через ISO-строку:

row = await db_preparer.find_order(order_id)
assert row.created_at.isoformat() == "2026-01-15T10:00:00+00:00"

Или усечение до секунды при сравнении:

from datetime import datetime, timezone

def trunc_sec(d: datetime) -> datetime:
    return d.replace(microsecond=0)

assert trunc_sec(row.created_at) == trunc_sec(expected)

Но самый надёжный подход — хранить дефолт как литерал без субмикросекунд:

self._created_at: datetime = datetime(2026, 1, 15, 10, 0, 0, tzinfo=UTC)

datetime(year, month, day, hour, minute, second, tzinfo=...)microsecond по умолчанию 0. Такой объект при сохранении в timestamptz и чтении обратно вернёт ровно то же значение.

При явном задании времени в тесте:

fixed_time = datetime(2026, 5, 26, 10, 0, 0, tzinfo=UTC)
app.dependency_overrides[get_clock] = lambda: FakeClock(fixed_time)

datetime(...) без microsecond — безопасно.

Generator + DatabasePreparer

PYTS-12 / PYTS-9: generator строит объект, препарер вставляет его в БД. Связка в Arrange-фазе:

async def test_cancel_order_when_confirmed_writes_outbox(
    client: AsyncClient,
    db_preparer: OrderDatabasePreparer,
) -> None:
    order_id = UUID("22222222-2222-2222-2222-222222222222")
    customer_id = UUID("cust0001-0000-0000-0000-000000000001")

    customer = CustomerObjectGenerator().with_id(customer_id).build()
    order = (
        OrderObjectGenerator()
        .with_id(order_id)
        .with_customer_id(customer_id)
        .with_status("CONFIRMED")
        .build()
    )

    await (
        db_preparer
        .clear_orders()
        .clear_customers()
        .create_customer(customer)
        .create_order(order)
        .prepare()
    )

    response = await client.delete(
        f"/v1/orders/{order_id}",
        headers=success_token(),
    )

    assert response.status_code == 200
    assert response.json()["status"] == "CANCELLED"

    outbox = await db_preparer.find_outbox_events("OrderCancelled")
    assert len(outbox) == 1
    assert outbox[0]["payload"]["order_id"] == str(order_id)

Arrange-фаза читается сверху вниз: очистка → контекст (Customer) → данные для сценария (Order). Generator выделяет поля, влияющие на поведение: status="CONFIRMED".

Несколько generator-ов в одном тесте

Когда сценарий проверяет поведение при нескольких связанных объектах:

async def test_create_order_when_product_out_of_stock_returns_409(
    client: AsyncClient,
    db_preparer: OrderDatabasePreparer,
) -> None:
    product_id = UUID("prod0001-0000-0000-0000-000000000001")
    customer_id = UUID("cust0002-0000-0000-0000-000000000002")

    product = ProductObjectGenerator().with_id(product_id).with_stock(0).build()
    customer = CustomerObjectGenerator().with_id(customer_id).build()

    await (
        db_preparer
        .clear_orders()
        .clear_products()
        .clear_customers()
        .create_customer(customer)
        .create_product(product)
        .prepare()
    )

    response = await client.post(
        "/v1/orders",
        json={"product_id": str(product_id), "quantity": 1},
        headers=success_token(),
    )

    assert response.status_code == 409

Каждый generator — своя переменная. Имя переменной (product, customer) читается как «в БД есть продукт с нулевым stock и покупатель». Поля не важные для сценария скрыты дефолтами.

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

АнтипаттернПравилоЧто взамен
Dict с 10+ полями прямо в теле тестаPYTS-12ObjectGenerator с разумными дефолтами
datetime.now(tz=UTC) в дефолтах generator-аPYTS-13фиксированный UTC-литерал datetime(2026, 1, 15, ...)
assert row.created_at == datetime.now(tz=UTC)PYTS-14фиксированное значение или сравнение с усечением
Один generator на все таблицыPYTS-12отдельный класс на каждую таблицу/сущность
build() мутирует и возвращает тот же объектPYTS-12каждый build() — новый dataclass-экземпляр
Generator как зависимость FastAPI или pytest-фикстураPYTS-13plain Python-класс, OrderObjectGenerator()
Прямой INSERT в теле теста вместо generator + preparerPYTS-12generator.build()preparer.create_*(...)prepare()
naive datetime без tzinfoPYTS-14всегда tzinfo=UTC; naive → расхождение с timestamptz

Куда дальше

  • python/database-preparer — куда передаётся результат build().
  • python/one-test — полный пример теста с generator в Arrange-фазе.
  • python/basics — принципы интеграционного теста и детерминизм через dependency_overrides.
  • PG schema — типы и точностьtimestamptz и почему важны микросекунды.