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:
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).