| name | python-fastapi-test-conventions |
| description | 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. |
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?
def test_get_by_id_returns_none_when_not_found():
result = repo.get_by_id(some_uuid)
assert result is None
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
-
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:
def test_create_user():
UserFactory(email="test@test.com")
session.flush()
assert session.query(UserORM).count() == 1
def test_duplicate_email_raises_integrity_error():
UserFactory(email="test@test.com")
UserFactory(email="test@test.com")
with pytest.raises(IntegrityError):
session.flush()
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.
@patch("src.services.users.user.UserRepository")
def test_create_user(mock_repo):
...
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.
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():
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).
def test_create_user_returns_none():
...
def test_create_user_raises_duplicate_error():
...
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.
assert {u.name for u in users} == {"Alice", "Bob"}
assert len(users) == 2
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.
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
yield session
session.close()
transaction.rollback()
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
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