7.5 KiB
Web Backend Testing Guide
Overview
The test suite lives in code/tests/. All tests use pytest and run against the real FastAPI app via TestClient. This guide covers patterns, conventions, and how to write new tests correctly.
Running Tests
# All tests
.venv/Scripts/python.exe -m pytest -q
# Specific files (always use explicit paths — no wildcards)
.venv/Scripts/python.exe -m pytest code/tests/test_commanders_route.py code/tests/test_validation.py -q
# Fast subset (locks + summary utils)
# Use the VS Code task: pytest-fast-locks
Always use the full venv Python path — never python or pytest directly.
Test File Naming
Name test files by the functionality they test, not by milestone or ticket:
| Good | Bad |
|---|---|
test_commander_search.py |
test_m3_validation.py |
test_error_handling.py |
test_phase2_routes.py |
test_include_exclude_validation.py |
test_milestone_4_fixes.py |
One file per logical area. Merge overlapping coverage rather than creating many small files.
Test Structure
Class-based grouping (preferred)
Group related tests into classes. Use descriptive method names that read as sentences:
class TestCommanderSearch:
def test_empty_query_returns_no_candidates(self, client):
...
def test_exact_match_returns_top_result(self, client):
...
def test_fuzzy_match_works_for_misspellings(self, client):
...
Standalone functions (acceptable for simple cases)
def test_health_endpoint_returns_200(client):
resp = client.get("/health")
assert resp.status_code == 200
Route Tests
Route tests use TestClient against the real app. Use monkeypatch to isolate external dependencies (CSV reads, session state, orchestrator calls).
from fastapi.testclient import TestClient
from code.web.app import app
@pytest.fixture
def client(monkeypatch):
# Patch heavy dependencies before creating client
monkeypatch.setattr("code.web.services.orchestrator.commander_candidates", lambda q, limit=10: [])
with TestClient(app) as c:
yield c
Key rules:
- Always use
with TestClient(app) as c:(context manager) so lifespan events run - Pass
raise_server_exceptions=Falsewhen testing error handlers:with TestClient(app, raise_server_exceptions=False) as c: yield c - Set session cookies when routes read session state:
resp = client.get("/build/step2", cookies={"sid": "test-sid"})
Example: route with session
from code.web.services.tasks import get_session
@pytest.fixture
def client_with_session(monkeypatch):
sid = "test-session-id"
session = get_session(sid)
session["commander"] = {"name": "Atraxa, Praetors' Voice", "ok": True}
with TestClient(app) as c:
c.cookies.set("sid", sid)
yield c
Example: HTMX request
def test_step2_htmx_partial(client_with_session):
resp = client_with_session.get(
"/build/step2",
headers={"HX-Request": "true"}
)
assert resp.status_code == 200
# HTMX partials are HTML fragments, not full pages
assert "<html>" not in resp.text
Example: error handler response shape
def test_invalid_commander_returns_400(client):
resp = client.post("/build/step1/confirm", data={"name": ""})
assert resp.status_code == 400
# Check standardized error shape from M4
data = resp.json()
assert data["error"] is True
assert "request_id" in data
Service / Unit Tests
Service tests don't need TestClient. Test classes directly with mocked dependencies.
from code.web.services.base import BaseService, ValidationError
class TestMyService:
def test_validate_raises_on_false(self):
svc = BaseService()
with pytest.raises(ValidationError, match="must not be empty"):
svc._validate(False, "must not be empty")
For services with external I/O (CSV reads, API calls), use monkeypatch or unittest.mock.patch:
from unittest.mock import patch, MagicMock
def test_catalog_loader_caches_result(monkeypatch):
mock_data = [{"name": "Test Commander"}]
with patch("code.web.services.commander_catalog_loader._load_from_disk", return_value=mock_data):
result = load_commander_catalog()
assert len(result.entries) == 1
Validation Tests
Pydantic model tests are pure unit tests — no fixtures needed:
from code.web.validation.models import BuildRequest
from pydantic import ValidationError
class TestBuildRequest:
def test_commander_required(self):
with pytest.raises(ValidationError):
BuildRequest(commander="")
def test_themes_defaults_to_empty_list(self):
req = BuildRequest(commander="Atraxa, Praetors' Voice")
assert req.themes == []
Exception / Error Handling Tests
Use a minimal inline app to test exception handlers in isolation — avoids loading the full app stack:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from code.exceptions import DeckBuilderError, CommanderValidationError
from code.web.utils.responses import deck_builder_error_response
@pytest.fixture(scope="module")
def error_test_client():
mini_app = FastAPI()
@mini_app.get("/raise")
async def raise_error(request: Request):
raise CommanderValidationError("test error")
@mini_app.exception_handler(Exception)
async def handler(request, exc):
if isinstance(exc, DeckBuilderError):
return deck_builder_error_response(request, exc)
raise exc
with TestClient(mini_app, raise_server_exceptions=False) as c:
yield c
def test_commander_error_returns_400(error_test_client):
resp = error_test_client.get("/raise")
assert resp.status_code == 400
assert resp.json()["error_type"] == "CommanderValidationError"
See code/tests/test_error_handling.py for complete examples.
Environment & Fixtures
conftest.py globals
code/tests/conftest.py provides:
ensure_test_environment(autouse) — setsALLOW_MUST_HAVES=1and restores env after each test
Test data
CSV test data lives in csv_files/testdata/. Point tests there with:
monkeypatch.setenv("CSV_FILES_DIR", str(Path("csv_files/testdata").resolve()))
Clearing caches between tests
Some services use module-level caches. Clear them in fixtures to avoid cross-test pollution:
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
@pytest.fixture(autouse=True)
def reset_catalog():
clear_commander_catalog_cache()
yield
clear_commander_catalog_cache()
Coverage Targets
| Layer | Target | Notes |
|---|---|---|
| Validation models | 95%+ | Pure Pydantic, easy to cover |
| Service layer | 80%+ | Mock external I/O |
| Route handlers | 70%+ | Cover happy path + key error paths |
| Exception handlers | 90%+ | Covered by test_error_handling.py |
| Utilities | 90%+ | responses.py, telemetry.py |
Run coverage:
.venv/Scripts/python.exe -m pytest --cov=code/web --cov-report=term-missing -q
What Not to Test
- Framework internals (FastAPI routing, Starlette middleware behavior)
- Trivial getters/setters with no logic
- Template rendering correctness (covered by template validation tests)
- Third-party library behavior (Pydantic, SQLAlchemy, etc.)
Focus tests on your logic: validation rules, session state transitions, error mapping, orchestrator integration.