Опирается на правила: PYTS-15PYTS-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_3PYTS-16test_<action>_when_<condition>_<expected>
JWT-токен строкой в теле тестаPYTS-18фикстура success_token() / customer_token(id)
AsyncClient(...) создаётся внутри каждого тестаPYTS-15общая фикстура из conftest.py
TestClient в интеграционном тесте с async-роутерамиPYTS-17AsyncClient + ASGITransport
Один гигантский тест на весь lifecyclePYTS-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-коды и их источник.