🚀
9. Testing & Quality
++++
Engineering
Apr 2026×12 min read

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

Driptanil Datta
Driptanil DattaSoftware Developer

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_service

The 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 == 401

These 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 True

But 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 dependencies

ruff 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.

Drip

Driptanil Datta

Software Developer

Building full-stack systems, one commit at a time. This blog is a centralized learning archive for developers.

Legal Notes
Disclaimer

The content provided on this blog is for educational and informational purposes only. While I strive for accuracy, all information is provided "as is" without any warranties of completeness, reliability, or accuracy. Any action you take upon the information found on this website is strictly at your own risk.

Copyright & IP

Certain technical content, interview questions, and datasets are curated from external educational sources to provide a centralized learning resource. Respect for original authorship is maintained; no copyright infringement is intended. All trademarks, logos, and brand names are the property of their respective owners.

System Operational

© 2026 Driptanil Datta. All rights reserved.