Skip to content

Testing

Test behavior at the narrowest useful boundary: use cases directly for application logic, the typed client for contract behavior, and raw HTTP for exact envelopes and headers.

Test use cases directly

async def test_create_todo_persists() -> None:
    repository = MemoryTodoRepository()
    context = AppContext(todos=repository)

    todo = await create_todo(CreateTodo(title="Buy milk"), context)

    assert todo.completed is False
    assert await repository.get(todo.id) == todo

These tests need no ASGI application and make authorization and domain behavior easy to exercise.

Use the typed in-process client

from tenchi.testing import open_client


async with open_client(app) as client:
    created = await client.call_with_response(
        create_todo_contract,
        request=CreateTodo(title="Buy milk"),
    )

assert created.body.title == "Buy milk"
assert created.headers.location.endswith(created.body.id)

open_client() runs the application lifespan and uses httpx's ASGI transport. It provides the same contract validation as a network client. Pass observers= to collect the same payload-safe ClientOutcome values that a deployed client emits.

Assert raw HTTP behavior

from tenchi.testing import open_http


async with open_http(app) as http:
    response = await http.post(
        "/todos",
        json={"title": ""},
    )

assert response.status_code == 422
assert response.headers["x-tenchi-error-source"] == "framework"
assert response.json()["code"] == "VALIDATION_ERROR"

Use raw HTTP for malformed input, media types, request IDs, middleware, application error envelopes, and other wire-level behavior.

Replace ports, not framework internals

Build a fresh application around memory adapters for behavior-focused integration tests. Use temporary real adapters when lifecycle, transactions, or persistence are the behavior under test; the generated starter verifies that todos survive a new application instance against the same temporary SQLite database. Avoid monkeypatching route dispatch or internal Tenchi functions—the purpose of these tests is to exercise the real boundary.

Verify store adapters

Run Tenchi's conformance checks against each idempotency or rate-limit adapter you plan to deploy:

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from tenchi.idempotency import IdempotencyStore
from tenchi.testing import verify_idempotency_store


class TestClock:
    def __init__(self, value: float = 1_000.0) -> None:
        self.value = value

    def __call__(self) -> float:
        return self.value

    def advance(self, seconds: float) -> None:
        self.value += seconds


clock = TestClock()


@asynccontextmanager
async def open_store() -> AsyncGenerator[IdempotencyStore]:
    async with open_test_connection() as connection:
        try:
            yield PostgresIdempotencyStore(connection, clock=clock)
        except BaseException:
            await connection.rollback()
            raise
        else:
            await connection.commit()


async def test_postgres_idempotency_store_conforms() -> None:
    await verify_idempotency_store(open_store, advance=clock.advance)

Replace open_test_connection() and PostgresIdempotencyStore with your adapter's test connection and constructor. open_store() must open a fresh independently usable scope over one shared test backend. A successful scope must persist before it exits. The adapter and the advance(seconds) callback must use the same deterministic clock; the callback may be synchronous or asynchronous.

verify_idempotency_store() checks the reservation, replay, conflict, expiration, token-fencing, identity-isolation, and concurrent-winner rules. Use verify_rate_limit_store() with the same factory shape for fixed-window capacity, rejected costs, reset boundaries, policy changes, isolation, and concurrent admission. Failures raise StoreConformanceError with the store kind, case name, and reason.

These checks verify the protocol state machine. Keep separate integration tests for behavior outside that contract: transaction rollback with application writes, migrations, cleanup, backend clock configuration, and coordination across the processes or hosts you deploy.

Keep OpenAPI reproducible

Generated applications include a test that runs:

from tenchi.cli import main


def test_openapi_snapshot_is_current() -> None:
    assert main(["openapi", "--check", "openapi.json"]) == 0

This checks exact drift. Compatibility belongs in CI against a historical baseline; see OpenAPI and compatibility.

Generated applications also keep the machine-facing tool contract current:

from tenchi.cli import main


def test_tool_snapshot_is_current() -> None:
    assert main(["tools", "--check", "tools.json"]) == 0

Run tenchi tools --diff against the previous snapshot before replacing it. The exact test proves reproducibility; the historical diff proves compatibility. See Application tools for the complete workflow.