Testing Guidelines
Test pyramid, conventions, and rules for the backend codebase.
Test Pyramid
| Layer |
What to test |
DB needed? |
Mock what? |
Volume |
| Repository |
Queries return correct results |
Yes (test DB) |
Nothing |
Per query method |
| Service |
Business logic, orchestration |
No |
Repositories |
Most tests here |
| Integration |
Route → Service → DB wiring |
Yes (test DB) |
Nothing |
Happy path only |
| Domain |
Business rules, invariants |
No |
Nothing |
Only when entities contain logic |
Repository tests
Use a real test database (not mocks)
Test each query method: found, not found, edge cases
Verify constraints (unique, FK, nullable)
Repositories return None for not-found queries — use scalar_one_or_none(). Tests assert None, not pytest.raises
Not-found tests must populate the table first — create a record via factory before asserting None for a different query.
Testing against an empty table proves nothing — of course it returns None when there's no data.
The real test is: can this query correctly return None when data exists but doesn't match the filter?
# Bad — empty table, trivially passes
def test_get_by_id_returns_none_when_not_found():
result = repo.get_by_id(some_uuid)
assert result is None # table is empty, this proves nothing
# Good — data exists, but query filters correctly
def test_get_by_id_returns_none_when_not_found():
UserFactory(id=UUID("00000000-0000-0000-0000-000000000001"))
result = repo.get_by_id(UUID("a1b2c3d4-5678-9abc-def0-1234567890ab"))
assert result is None # proves the query filters, not just that the table is empty
Use explicit IDs when the test references them — pass id=UUID(...) to the factory so the UUID is visible
Update tests must prove a change happened — create with initial value, update to different value, assert updated value
Assert every input field — including foreign keys like tenant_id
Never call session.flush() in tests — flush() sends pending SQL to the database without committing.
In tests with transaction rollback, this can cause confusing behavior — the test might pass because flush() made
data visible within the same session, but in production (where commit() is used), the behavior could differ.
The only exception is constraint violation tests — flush() is required there because IntegrityError only fires
when SQL actually hits the database:
# Bad — flush in a regular test, hides commit-time behavior differences
def test_create_user():
UserFactory(email="test@test.com")
session.flush() # unnecessary, can mask production behavior
assert session.query(UserORM).count() == 1
# Good — flush only for constraint violation tests
def test_duplicate_email_raises_integrity_error():
UserFactory(email="test@test.com")
UserFactory(email="test@test.com")
with pytest.raises(IntegrityError):
session.flush() # forces SQL execution, triggers unique constraint
Service tests
Inject mock repos via constructor — pass Mock(spec=UserRepository) directly. Never use @patch for repos.
Constructor injection makes dependencies explicit and testable. @patch hides what's being mocked and
breaks when modules are renamed or moved.
# Bad — @patch hides the dependency, fragile to refactoring
@patch("src.services.users.user.UserRepository")
def test_create_user(mock_repo):
...
# Good — explicit injection, clear what's mocked
def test_create_user():
repo = Mock(spec=UserRepository)
service = UserService(repo, session)
...
Test business logic: success paths, error paths, edge cases
Assert all orchestration calls — verify every repo method was called with the correct args
Client tests (external API wrappers)
Inject mock HTTP client via constructor — pass Mock(spec=httpx.Client) directly. Never use @patch.
Test success paths, HTTP error handling, and response validation
Integration tests
Hit actual HTTP endpoints via FastAPI TestClient. Real DB, real middleware.
Happy path only — one test per endpoint
Domain tests
- Pure unit tests, no mocks, no DB.
Only write domain tests when domain entities contain actual business logic
(validation, calculations, state transitions). Plain data containers
(dataclasses with fields only) have nothing to test.
Conventions
Test file naming: test_{module}.py mirroring the source file
Test function naming: test_{method}_{scenario} where scenario includes the result. Never vague names.
# Good — method + scenario + result
def test_get_by_id_returns_none_when_not_found():
def test_create_category_raises_duplicate_error_when_name_exists():
def test_list_users_returns_empty_list_when_no_users():
# Bad — vague, no result
def test_get_by_id():
def test_create_category_error():
def test_list_users():
One assertion per test where practical
Test directory mirrors source:
src/services/users/user.py → tests/unit/services/users/test_user.py
src/persistence/repositories/ → tests/unit/persistence/repositories/
src/api/routes/ → tests/unit/api/routes/
Variable naming: Name variables by their type — tenant_orm not result
Inline test values — NEVER extract test data into module-level constants
Prefer flat functions over classes — avoid class in test files. Each test should be independent
with its own setup. Use conftest.py fixtures for shared setup. Classes are acceptable when testing
a stateful object that requires multi-step interaction (e.g., testing a state machine's transitions,
or a builder pattern where each test calls methods in sequence on the same instance).
# Good — flat functions (default for most tests)
def test_create_user_returns_none():
...
def test_create_user_raises_duplicate_error():
...
# Good — class for stateful multi-step interaction
class TestOrderStateMachine:
def setup_method(self):
self.order = Order(status=OrderStatus.DRAFT)
def test_submit_moves_to_pending(self):
self.order.submit()
assert self.order.status == OrderStatus.PENDING
def test_submit_twice_raises_error(self):
self.order.submit()
with pytest.raises(InvalidTransitionError):
self.order.submit()
No test helper functions in test files — inline setup. Shared helpers go in conftest.py
Always use Mock — never use MagicMock. Mock is sufficient for all standard mocking needs.
Assert error details — verify error messages/codes, not just status codes
Assert elements explicitly — prefer list comparison or index-based assertions over set comprehensions,
which lose ordering and hide duplicates.
# Bad — set comprehension loses order, hides duplicates
assert {u.name for u in users} == {"Alice", "Bob"}
# Bad — just checking length, doesn't verify content
assert len(users) == 2
# Good — explicit, ordered, catches wrong data
assert users[0].name == "Alice"
assert users[1].name == "Bob"
No unnecessary intermediate variables — assert inline
Never create variables just to avoid repeating literals — repetition is clarity
Test Infrastructure
Tests require a running PostgreSQL instance — start a local database before running tests
(e.g., via Docker Compose, a Makefile target, or a local Postgres installation)
pytest as test runner, SQLAlchemy session per test (rolled back), FastAPI TestClient for integration, factory_boy
conftest.py Setup
The root conftest.py wires together the session, factories, and test client. Every test gets a fresh transaction that rolls back on teardown — no data leaks between tests, no truncation needed.
# tests/conftest.py
TEST_DATABASE_URL = os.environ["DATABASE_URL"]
@pytest.fixture(scope="session")
def engine() -> Generator[Engine, None, None]:
test_engine = create_engine(TEST_DATABASE_URL)
Base.metadata.create_all(bind=test_engine)
yield test_engine
Base.metadata.drop_all(bind=test_engine)
test_engine.dispose()
@pytest.fixture
def db_session(engine: Engine) -> Generator[Session, None, None]:
connection = engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
for factory_class in BaseFactory.__subclasses__():
factory_class._meta.sqlalchemy_session = session # wire factories to test session
yield session
session.close()
transaction.rollback() # discards everything created in the test
connection.close()
@pytest.fixture
def client(db_session: Session) -> Generator[TestClient, None, None]:
def _override_get_db() -> Generator[Session, None, None]:
yield db_session
app.dependency_overrides[get_db] = _override_get_db # inject test session into routes
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
Key points:
engine is scope="session" — schema is created once per test run, not per test
db_session is function-scoped — each test gets its own transaction, rolled back after
- Factories are wired to the same session so factory-created data is visible within the test
client overrides the get_db dependency so integration tests hit the same rolled-back session
Additional Resources
references/factory-conventions.md — factory_boy setup, fixed values, UUID patterns, factory defaults
references/test-structure.md — Full test directory tree, how to add unit/integration tests, folder creation policy
1---2name: python-fastapi-test-conventions3description: This skill should be used when writing unit tests, integration tests, creating test factories, mocking dependencies, or reviewing test coverage. Covers test pyramid, conventions, factories, and per-layer rules.4---56# Testing Guidelines78Test pyramid, conventions, and rules for the backend codebase.910---1112## Test Pyramid1314| Layer | What to test | DB needed? | Mock what? | Volume |15|--------------|----------------------------------|----------------|---------------|-----------------------|16| Repository | Queries return correct results | Yes (test DB) | Nothing | Per query method |17| Service | Business logic, orchestration | No | Repositories | Most tests here |18| Integration | Route → Service → DB wiring | Yes (test DB) | Nothing | Happy path only |19| Domain | Business rules, invariants | No | Nothing | Only when entities contain logic |2021---2223### Repository tests2425- Use a real test database (not mocks)2627- Test each query method: found, not found, edge cases2829- Verify constraints (unique, FK, nullable)3031- **Repositories return `None` for not-found queries** — use `scalar_one_or_none()`. Tests assert `None`, not `pytest.raises`3233- **Not-found tests must populate the table first** — create a record via factory before asserting `None` for a different query.34 Testing against an empty table proves nothing — of course it returns `None` when there's no data.35 The real test is: can this query correctly return `None` when data exists but doesn't match the filter?3637 ```python38 # Bad — empty table, trivially passes39 def test_get_by_id_returns_none_when_not_found():40 result = repo.get_by_id(some_uuid)41 assert result is None # table is empty, this proves nothing4243 # Good — data exists, but query filters correctly44 def test_get_by_id_returns_none_when_not_found():45 UserFactory(id=UUID("00000000-0000-0000-0000-000000000001"))46 result = repo.get_by_id(UUID("a1b2c3d4-5678-9abc-def0-1234567890ab"))47 assert result is None # proves the query filters, not just that the table is empty48 ```4950- **Use explicit IDs when the test references them** — pass `id=UUID(...)` to the factory so the UUID is visible5152- **Update tests must prove a change happened** — create with initial value, update to different value, assert updated value5354- **Assert every input field** — including foreign keys like `tenant_id`5556- **Never call `session.flush()` in tests** — `flush()` sends pending SQL to the database without committing.57 In tests with transaction rollback, this can cause confusing behavior — the test might pass because `flush()` made58 data visible within the same session, but in production (where `commit()` is used), the behavior could differ.5960 The only exception is constraint violation tests — `flush()` is required there because `IntegrityError` only fires61 when SQL actually hits the database:6263 ```python64 # Bad — flush in a regular test, hides commit-time behavior differences65 def test_create_user():66 UserFactory(email="test@test.com")67 session.flush() # unnecessary, can mask production behavior68 assert session.query(UserORM).count() == 16970 # Good — flush only for constraint violation tests71 def test_duplicate_email_raises_integrity_error():72 UserFactory(email="test@test.com")73 UserFactory(email="test@test.com")74 with pytest.raises(IntegrityError):75 session.flush() # forces SQL execution, triggers unique constraint76 ```7778---7980### Service tests8182- **Inject mock repos via constructor** — pass `Mock(spec=UserRepository)` directly. Never use `@patch` for repos.83 Constructor injection makes dependencies explicit and testable. `@patch` hides what's being mocked and84 breaks when modules are renamed or moved.8586 ```python87 # Bad — @patch hides the dependency, fragile to refactoring88 @patch("src.services.users.user.UserRepository")89 def test_create_user(mock_repo):90 ...9192 # Good — explicit injection, clear what's mocked93 def test_create_user():94 repo = Mock(spec=UserRepository)95 service = UserService(repo, session)96 ...97 ```9899- Test business logic: success paths, error paths, edge cases100101- **Assert all orchestration calls** — verify every repo method was called with the correct args102103---104105### Client tests (external API wrappers)106107- **Inject mock HTTP client via constructor** — pass `Mock(spec=httpx.Client)` directly. Never use `@patch`.108109- Test success paths, HTTP error handling, and response validation110111---112113### Integration tests114115- Hit actual HTTP endpoints via FastAPI `TestClient`. Real DB, real middleware.116117- Happy path only — one test per endpoint118119---120121### Domain tests122123- Pure unit tests, no mocks, no DB.124 Only write domain tests when domain entities contain actual business logic125 (validation, calculations, state transitions). Plain data containers126 (dataclasses with fields only) have nothing to test.127128---129130## Conventions131132- **Test file naming:** `test_{module}.py` mirroring the source file133134- **Test function naming:** `test_{method}_{scenario}` where scenario includes the result. Never vague names.135136 ```python137 # Good — method + scenario + result138 def test_get_by_id_returns_none_when_not_found():139 def test_create_category_raises_duplicate_error_when_name_exists():140 def test_list_users_returns_empty_list_when_no_users():141142 # Bad — vague, no result143 def test_get_by_id():144 def test_create_category_error():145 def test_list_users():146 ```147148- **One assertion per test** where practical149150- **Test directory mirrors source:**151152 ```153 src/services/users/user.py → tests/unit/services/users/test_user.py154 src/persistence/repositories/ → tests/unit/persistence/repositories/155 src/api/routes/ → tests/unit/api/routes/156 ```157158- **Variable naming:** Name variables by their type — `tenant_orm` not `result`159160- **Inline test values** — NEVER extract test data into module-level constants161162- **Prefer flat functions over classes** — avoid `class` in test files. Each test should be independent163 with its own setup. Use `conftest.py` fixtures for shared setup. Classes are acceptable when testing164 a stateful object that requires multi-step interaction (e.g., testing a state machine's transitions,165 or a builder pattern where each test calls methods in sequence on the same instance).166167 ```python168 # Good — flat functions (default for most tests)169 def test_create_user_returns_none():170 ...171172 def test_create_user_raises_duplicate_error():173 ...174175 # Good — class for stateful multi-step interaction176 class TestOrderStateMachine:177 def setup_method(self):178 self.order = Order(status=OrderStatus.DRAFT)179180 def test_submit_moves_to_pending(self):181 self.order.submit()182 assert self.order.status == OrderStatus.PENDING183184 def test_submit_twice_raises_error(self):185 self.order.submit()186 with pytest.raises(InvalidTransitionError):187 self.order.submit()188 ```189190- **No test helper functions in test files** — inline setup. Shared helpers go in `conftest.py`191192- **Always use `Mock`** — never use `MagicMock`. `Mock` is sufficient for all standard mocking needs.193194- **Assert error details** — verify error messages/codes, not just status codes195196- **Assert elements explicitly** — prefer list comparison or index-based assertions over set comprehensions,197 which lose ordering and hide duplicates.198199 ```python200 # Bad — set comprehension loses order, hides duplicates201 assert {u.name for u in users} == {"Alice", "Bob"}202203 # Bad — just checking length, doesn't verify content204 assert len(users) == 2205206 # Good — explicit, ordered, catches wrong data207 assert users[0].name == "Alice"208 assert users[1].name == "Bob"209 ```210211- **No unnecessary intermediate variables** — assert inline212213- **Never create variables just to avoid repeating literals** — repetition is clarity214215---216217## Test Infrastructure218219- **Tests require a running PostgreSQL instance** — start a local database before running tests220 (e.g., via Docker Compose, a Makefile target, or a local Postgres installation)221222- **pytest** as test runner, SQLAlchemy session per test (rolled back), FastAPI `TestClient` for integration, factory_boy223224### conftest.py Setup225226The root `conftest.py` wires together the session, factories, and test client. Every test gets a fresh transaction that rolls back on teardown — no data leaks between tests, no truncation needed.227228```python229# tests/conftest.py230TEST_DATABASE_URL = os.environ["DATABASE_URL"]231232@pytest.fixture(scope="session")233def engine() -> Generator[Engine, None, None]:234 test_engine = create_engine(TEST_DATABASE_URL)235 Base.metadata.create_all(bind=test_engine)236 yield test_engine237 Base.metadata.drop_all(bind=test_engine)238 test_engine.dispose()239240@pytest.fixture241def db_session(engine: Engine) -> Generator[Session, None, None]:242 connection = engine.connect()243 transaction = connection.begin()244 session = sessionmaker(bind=connection)()245246 for factory_class in BaseFactory.__subclasses__():247 factory_class._meta.sqlalchemy_session = session # wire factories to test session248249 yield session250251 session.close()252 transaction.rollback() # discards everything created in the test253 connection.close()254255@pytest.fixture256def client(db_session: Session) -> Generator[TestClient, None, None]:257 def _override_get_db() -> Generator[Session, None, None]:258 yield db_session259260 app.dependency_overrides[get_db] = _override_get_db # inject test session into routes261 with TestClient(app) as test_client:262 yield test_client263 app.dependency_overrides.clear()264```265266Key points:267- `engine` is `scope="session"` — schema is created once per test run, not per test268- `db_session` is function-scoped — each test gets its own transaction, rolled back after269- Factories are wired to the same session so factory-created data is visible within the test270- `client` overrides the `get_db` dependency so integration tests hit the same rolled-back session271272---273274## Additional Resources275276- `references/factory-conventions.md` — factory_boy setup, fixed values, UUID patterns, factory defaults277278- `references/test-structure.md` — Full test directory tree, how to add unit/integration tests, folder creation policy