Опирается на правила:
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с БД — PostgreSQLtimestamptzхранит микросекунды; Pythondatetime.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-12 | ObjectGenerator с разумными дефолтами |
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-13 | plain Python-класс, OrderObjectGenerator() |
Прямой INSERT в теле теста вместо generator + preparer | PYTS-12 | generator.build() → preparer.create_*(...) → prepare() |
naive datetime без tzinfo | PYTS-14 | всегда tzinfo=UTC; naive → расхождение с timestamptz |
Куда дальше
- python/database-preparer — куда передаётся результат
build(). - python/one-test — полный пример теста с generator в Arrange-фазе.
- python/basics — принципы интеграционного теста и детерминизм через
dependency_overrides. - PG schema — типы и точность —
timestamptzи почему важны микросекунды.