++++A layered testing strategy for this architecture — how to test services without a database, how to test auth middleware behavior, and which quality gates to treat as mandatory.
9. Testing & Quality
Testing at the Right Layer
One of the payoffs of strict layering is that you can test each layer in isolation. You don't need a real database to test business logic. You don't need a real service to test HTTP routing. This keeps tests fast and focused — when a test fails, you know exactly which layer broke.
The test suite in this template uses three different strategies: service-level unit tests with mocked repositories, integration tests against the HTTP layer with mocked services, and middleware tests that exercise the full auth enforcement path.
Testing the Service Layer
tests/test_todo_crud.py tests TodoService directly, without any HTTP or database involvement. The repository is mocked using AsyncMock, which respects the TodoRepository spec:
# tests/test_todo_crud.py
@pytest.fixture
def mock_repo():
return AsyncMock(spec=TodoRepository)
@pytest.fixture
def todo_service(mock_repo):
return TodoService(mock_repo)Because TodoService depends on the repository interface — not on SQLAlchemy directly — swapping in a mock is clean and complete. The test for create_todo verifies the returned domain object and that repo.save was called once:
@pytest.mark.asyncio
async def test_create_todo(todo_service, mock_repo):
todo = await todo_service.create_todo("Test Todo", "Test Description")
assert todo.title == "Test Todo"
assert todo.status == TodoStatus.PENDING
assert isinstance(todo.id, UUID)
mock_repo.save.assert_called_once()The test for mark_completed sets up the repository to return a specific pending todo, then verifies the service transitions it to COMPLETED:
@pytest.mark.asyncio
async def test_mark_completed(todo_service, mock_repo):
existing_todo = Todo(id=todo_id, status=TodoStatus.PENDING, ...)
mock_repo.get_by_id.return_value = existing_todo
updated = await todo_service.mark_completed(todo_id)
assert updated.status == TodoStatus.COMPLETED
mock_repo.update.assert_called_once()These tests are testing the business logic — the state transitions and the domain contract — not the SQL. If you change from SQLite to Postgres later, these tests don't change.
Testing Middleware Behavior
Auth middleware tests live in tests/test_auth_middleware.py. They build a minimal FastAPI app with real middleware and a mocked service, then drive requests through TestClient. This tests the actual HTTP enforcement:
# tests/test_auth_middleware.py
def _build_app() -> tuple[FastAPI, AsyncMock]:
app = FastAPI()
todo_service = AsyncMock()
todo_service.list_todos.return_value = [_todo()]
app.state.todo_service = todo_service
app.state.auth_service = AuthService()
init_routes(app)
attach_auth_middleware(app)
return app, todo_serviceThe AuthService here is the real one — you want to test that actual JWT validation and basic auth work correctly. Only the TodoService is mocked, because the tests are about auth behavior, not data retrieval.
The critical tests are the negative paths:
def test_protected_todo_route_requires_auth() -> None:
app, _ = _build_app()
with TestClient(app) as client:
response = client.get("/api/todos/")
assert response.status_code == 401
def test_protected_todo_route_rejects_invalid_bearer() -> None:
app, _ = _build_app()
with TestClient(app) as client:
response = client.get(
"/api/todos/",
headers={"Authorization": "Bearer invalid-token"},
)
assert response.status_code == 401These confirm that unauthenticated and malformed requests are rejected. The positive paths — valid basic auth and a freshly issued JWT — confirm that the allowed flows work end-to-end.
Why Happy Paths Alone Are Not Enough
It's easy to write tests that only exercise the success case:
# This test proves very little
async def test_create_only_success():
assert TrueBut what protects your production behavior are the rejection tests. A 401 for an unauthenticated request, a 422 for a payload with an unknown field, a 404 for a missing todo — these are the behaviors that matter when someone sends unexpected input or probes for vulnerabilities.
Negative-path tests are also the first to catch regressions when middleware is changed. If you remove a path prefix from PROTECTED_PATH_PREFIXES by accident, the test that expects a 401 on an unauthenticated request will immediately tell you.
The Four Quality Gates
Running pytest alone is necessary but not sufficient. These four commands should run on every PR:
poetry run ruff check . # lint and formatting
poetry run python -m pytest # all tests
poetry run bandit -r server # static security analysis
poetry run pip-audit # CVE scan on dependenciesruff catches issues like unused imports, shadowed variables, and style violations — things that are technically valid Python but create maintenance problems. bandit looks for security antipatterns: hardcoded passwords, shell injection risks, use of os.system. pip-audit checks your dependency tree against vulnerability databases — a transitive dependency with a known CVE is still your problem.
Each tool covers a class of issue the others miss. Running all four in CI makes them non-optional for any change that reaches the main branch.