Опирается на правила:
PYTS-15…PYTS-18из Python Test Strategy → раздел 5. Структура одного теста.
Важно знать
AsyncClientчерезASGITransport(app=app)— реальный ASGI-стек без поднятия TCP-сервера.- Имена функций:
test_<action>_when_<condition>_<expected>; docstring первой строкой — BR-код.- JWT — через фикстуру
success_token()илиcustomer_token(customer_id), не собираем токен руками в каждом тесте.DatabasePreparerинжектируется через pytest-фикстуру, не создаётся внутри теста.- AAA-структура с пустыми строками между блоками
# Arrange,# Act,# Assert.- Assert на response + state в БД через
DatabasePreparerили прямойAsyncSession.- Один тест — один сценарий. Мега-тест на весь lifecycle запрещён.
Полный пример одного теста. Это шаблон, на который ориентируются все интеграционные тесты в Python-сервисе.
Полный пример
async def test_confirm_order_when_reservation_failed_returns_409(
client: AsyncClient,
order_preparer: OrderDatabasePreparer,
success_token: str,
) -> None:
"""BR-002: нельзя подтвердить заказ в статусе RESERVATION_FAILED."""
order_id = UUID("11111111-1111-1111-1111-111111111111")
# Arrange
failed_order = (
OrderGenerator()
.with_id(order_id)
.with_status(OrderStatus.RESERVATION_FAILED)
.build()
)
await order_preparer.create_order(failed_order).prepare()
# Act
response = await client.post(
f"/v1/orders/{order_id}/confirm",
headers={"Authorization": f"Bearer {success_token}"},
)
# Assert
assert response.status_code == 409
assert response.json()["code"] == "OUT_OF_STOCK"
row = await order_preparer.find_order(order_id)
assert row.status == "RESERVATION_FAILED"
Что есть в примере:
- Имя функции
test_confirm_order_when_reservation_failed_returns_409— три части через_: действие, условие, ожидаемый результат. - Docstring первой строкой ссылается на конкретное бизнес-правило
BR-002— это traceability: spec → test → код. - Фикстуры
client,order_preparer,success_tokenинжектируются pytest, не создаются внутри теста. OrderGenerator()из раздела ObjectGenerator — fluent builder с разумными дефолтами.await order_preparer.create_order(...).prepare()— Arrange setup черезDatabasePreparer.await client.post(...)— реальный ASGI-стек черезASGITransport.- Assert на response + финальный state в БД.
Фикстура AsyncClient
PYTS-15: тест использует общую AsyncClient-фикстуру, не поднимает приложение руками.
@pytest.fixture
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
yield ac
ASGITransport проводит запрос через весь ASGI-стек — middleware, dependency injection, обработчики исключений — без TCP. Скорость близка к прямому вызову функции, но проверяется реальная сериализация, статус-коды, заголовки.
Фикстура client объявлена в conftest.py платформенного уровня. В тесте её только инжектируют — client: AsyncClient в аргументах.
AsyncClient vs TestClient
| Инструмент | Когда |
|---|---|
AsyncClient + ASGITransport | Интеграционный тест: async-роутеры, middleware, dependency overrides |
TestClient (синхронный) | Unit-тест маршрутизации/сериализации без async-зависимостей |
Для интеграционных тестов на FastAPI — AsyncClient. TestClient оставляем для изолированного unit-теста контроллера с overrides-репозитория на фейк.
Имена тестов
PYTS-16: формат test_<action>_when_<condition>_<expected>.
# Хорошо
async def test_confirm_order_when_draft_returns_200(...)
async def test_confirm_order_when_reservation_failed_returns_409(...)
async def test_create_order_when_invalid_email_returns_422(...)
async def test_get_product_when_not_found_returns_404(...)
async def test_cancel_order_when_already_cancelled_returns_409(...)
BR-ссылка — docstring первой строкой:
async def test_confirm_order_when_reservation_failed_returns_409(
client: AsyncClient,
order_preparer: OrderDatabasePreparer,
success_token: str,
) -> None:
"""BR-002: нельзя подтвердить заказ в статусе RESERVATION_FAILED."""
...
Grep по BR-002 в *_test.py сразу покажет все тесты, которые нужно пересмотреть при изменении правила.
# Плохо — не описывает условие и ожидание
async def test_cancel(...)
async def test_order_1(...)
async def test_case_3(...)
async def test_order(...)
HTTP через AsyncClient
PYTS-17: вызов через await client.post(...) / .request(...) — полный контроль над методом, заголовками, телом.
response = await client.post(
f"/v1/orders/{order_id}/confirm",
headers={"Authorization": f"Bearer {success_token}"},
json={"comment": "подтверждаю"},
)
assert response.status_code == 200
data = response.json()
assert data["orderId"] == str(order_id)
assert data["status"] == "CONFIRMED"
assert response.headers.get("X-Request-Id") is not None
Для кастомных методов или точного контроля заголовков — .request(...):
response = await client.request(
method="DELETE",
url=f"/v1/orders/{order_id}",
headers={
"Authorization": f"Bearer {success_token}",
"Idempotency-Key": str(idempotency_key),
},
)
Unit-тест контроллера без БД пишется с override порта-репозитория на in-memory фейк:
app.dependency_overrides[get_order_repository] = lambda: FakeOrderRepository()
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
response = await ac.get("/v1/orders/nonexistent")
assert response.status_code == 404
JWT через хелпер-фикстуру
PYTS-18: JWT — через фикстуры success_token(), customer_token(customer_id), не собираем токены руками.
@pytest.fixture
def success_token(fake_jwt_settings: JwtSettings) -> str:
return build_test_token(fake_jwt_settings, role="customer")
@pytest.fixture
def customer_token(fake_jwt_settings: JwtSettings):
def _factory(customer_id: UUID) -> str:
return build_test_token(
fake_jwt_settings, role="customer", sub=str(customer_id)
)
return _factory
@pytest.fixture
def admin_token(fake_jwt_settings: JwtSettings) -> str:
return build_test_token(fake_jwt_settings, role="admin")
Использование в тесте:
async def test_get_order_when_not_owner_returns_403(
client: AsyncClient,
order_preparer: OrderDatabasePreparer,
customer_token,
) -> None:
"""BR-010: чужой заказ недоступен."""
owner_id = UUID("aaaaaaaa-0000-0000-0000-000000000001")
other_id = UUID("bbbbbbbb-0000-0000-0000-000000000002")
order_id = UUID("11111111-1111-1111-1111-111111111111")
# Arrange
order = OrderGenerator().with_id(order_id).with_customer_id(owner_id).build()
await order_preparer.create_order(order).prepare()
# Act
response = await client.get(
f"/v1/orders/{order_id}",
headers={"Authorization": f"Bearer {customer_token(other_id)}"},
)
# Assert
assert response.status_code == 403
Единый build_test_token — единственный source-of-truth. Изменится структура JWT-claims — меняем в одном месте, не во всех тестах.
# Плохо — токен руками в каждом тесте
headers = {"Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9..."}
AAA с пустыми строками
PYTS-3: три блока, пустая строка между ними.
async def test_create_order_when_customer_not_found_returns_404(
client: AsyncClient,
success_token: str,
) -> None:
"""BR-001: заказ нельзя создать для несуществующего клиента."""
customer_id = UUID("00000000-0000-0000-0000-000000000099")
# Arrange
# Act
response = await client.post(
"/v1/orders",
json={"customerId": str(customer_id), "amount": 500},
headers={"Authorization": f"Bearer {success_token}"},
)
# Assert
assert response.status_code == 404
assert response.json()["code"] == "CUSTOMER_NOT_FOUND"
Комментарии # Arrange / # Act / # Assert — не обязательны, но пустая строка между блоками обязательна. Это визуальный сигнал «новая фаза теста».
Не «проверить всё в одном»
# Плохо — мега-тест на весь lifecycle
async def test_order_lifecycle(client, success_token, order_preparer):
# создать заказ
# подтвердить
# оплатить
# отгрузить
# отменить
...
При провале шага 3 непонятно, что с шагами 4-5. Имя не описывает ни один конкретный сценарий.
Правильно — отдельные тесты:
async def test_confirm_order_when_draft_returns_200(...)
async def test_pay_order_when_confirmed_returns_200(...)
async def test_ship_order_when_paid_returns_200(...)
async def test_cancel_order_when_confirmed_returns_200(...)
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Имя test_cancel, test_order_1, test_case_3 | PYTS-16 | test_<action>_when_<condition>_<expected> |
| JWT-токен строкой в теле теста | PYTS-18 | фикстура success_token() / customer_token(id) |
AsyncClient(...) создаётся внутри каждого теста | PYTS-15 | общая фикстура из conftest.py |
TestClient в интеграционном тесте с async-роутерами | PYTS-17 | AsyncClient + ASGITransport |
| Один гигантский тест на весь lifecycle | PYTS-3 | один тест = один сценарий |
| Assert только на response, без проверки БД | PYTS-15 | проверять side-effects (строки в БД, Outbox) |
| docstring без BR-кода | PYTS-16 | """BR-NNN: ...""" первой строкой |
Куда дальше
- Базовые правила — PYTS-1..PYTS-3, детерминизм, AAA.
- Фикстуры и conftest —
AsyncClient,PostgresContainer,dependency_overrides. - DatabasePreparer — fluent setup БД:
create_*,clear_*,prepare(). - ObjectGenerator — fluent builders:
with_*,build(). - Kafka, Redis, async — по умолчанию НЕТ — Outbox-проверка в Assert.
- Внешние HTTP — pytest-httpserver — стабы внешних сервисов.
- Use Case Pattern → спецификация — BR-коды и их источник.