Importing the production FastAPI application in every test can initialize configuration, database pools, schedulers, and provider clients. Most route tests need none of those things.
Build a focused app and override its boundaries:
class DummyPool:
pass
def build_test_app(user_id: str = 'user-123') -> FastAPI:
app = FastAPI()
register_exception_handlers(app)
app.include_router(api_v1)
app.dependency_overrides[get_current_user] = lambda: user_id
app.dependency_overrides[get_db_pool] = lambda: DummyPool()
return app
@pytest.fixture
def client() -> TestClient:
return TestClient(build_test_app())
Authentication and infrastructure remain present as contracts, but tests control their results.
Test each layer for what it owns#
Router tests cover validation, authentication requirements, status codes, envelopes, and serialization.
Service tests cover permissions, transitions, orchestration, and failure behavior by replacing repositories or gateways.
Repository tests cover SQL mapping, constraints, and transactions against PostgreSQL where mocks cannot reproduce semantics.
Cross-cutting tests build a tiny app to prove middleware, error handlers, correlation headers, and streaming behavior once.
Patch the name the code uses#
If router.py imports create_workout directly, patch app.workouts.router.create_workout. Patching its original definition in service.py does not replace the already-bound router symbol.
Better yet, use explicit dependencies where substitution is common. Monkeypatching remains useful for module-level functions and provider SDKs.
Avoid tests that mirror implementation#
Assert that an unauthorized operation performs no write. Do not assert every private helper call. A refactor should preserve behavior without rewriting the entire suite.
Real integration tests still matter. A dummy pool cannot prove an ON CONFLICT clause, row lock, or migration. The goal is a test pyramid whose fast majority exercises decisions directly and whose smaller infrastructure layer proves the technology-specific promises.