Skip to content

Testing

Layout

tests/
├── api/              # HTTP-level tests (httpx ASGITransport)
│   └── v1/
├── cli/              # CLI entrypoint tests
├── db/               # Migration and schema drift tests
├── grpc/             # gRPC server tests
├── integration/      # Cross-DB integration tests
├── middleware/        # Middleware-layer tests
├── models/           # Model + schema tests
├── services/         # Service-layer tests
└── conftest.py       # Shared fixtures

Run all tests

The test stack is isolated: a dedicated Postgres on a private Docker network, a separate test image built from Dockerfile.test, and teardown on exit. The live app container is built without dev dependencies — never run pytest against it.

cd /home/sauer/projects/saebooks
bash scripts/run-tests.sh                              # full suite
bash scripts/run-tests.sh -k journal                   # keyword filter
bash scripts/run-tests.sh tests/api/v1/test_invoices.py  # single file

The Makefile test target (make test) runs pytest inside a running dev container and is suitable for interactive iteration only after the dev stack is already up:

make test       # docker compose exec app pytest -q --asyncio-mode=auto

Database

Tests use a real Postgres database (the same image as prod, fresh per run). Do not mock the database — see the project's history of mocked tests passing while real migrations failed in prod.

alembic upgrade head runs inside the test container before pytest, so schema is always migrated from scratch. Tests marked @pytest.mark.postgres_only are skipped automatically when the DATABASE_URL points at SQLite (Cashbook edition).

Async fixtures

Use pytest_asyncio decorators:

import pytest_asyncio

@pytest_asyncio.fixture
async def my_user() -> AsyncIterator[User]:
    async with AsyncSessionLocal() as session:
        user = User(...)
        session.add(user)
        await session.commit()
        await session.refresh(user)
        yield user
        await session.execute(sa_delete(User).where(User.id == user.id))
        await session.commit()

HTTP tests

Use httpx.AsyncClient with ASGITransport:

from httpx import ASGITransport, AsyncClient
from saebooks.main import app

async def test_endpoint():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        r = await client.get("/api/v1/...", headers={"Authorization": f"Bearer {token}"})
        assert r.status_code == 200

Coverage

Aim for high coverage on new code in saebooks/services/ and saebooks/api/. Money arithmetic changes require Hypothesis property tests (see hypothesis in the dev dependencies).