| name | testing-patterns |
| description | Pytest testing patterns, factory functions, mocking strategies, and TDD workflow. Use when writing unit tests, creating test factories, following TDD red-green-refactor cycle, or determining test coverage priorities. |
| requires-context | ["docs/decisions/business_rules.md","docs/architecture/ARCHITECTURE.md"] |
| skill_type | universal |
| version | 1.0.0 |
Testing Patterns and Utilities
Testing Philosophy
Test-Driven Development (TDD):
- Write failing test FIRST.
- Implement minimal code to pass.
- Refactor after green.
- Never write production code without a failing test.
- See
test-driven-development skill for the full Iron Law and Red-Green-Refactor cycle.
Behavior-Driven Testing:
- Test behavior, not implementation.
- Focus on public APIs (Services/Routers) and business requirements.
- Avoid testing implementation details (private methods).
- Use descriptive test names:
test_<action>_<condition>_<expected_outcome>.
Factory Pattern:
- Create
get_mock_x(**overrides) functions.
- Provide sensible defaults.
- Keep tests DRY and maintainable.
Coverage Priority Order
When deciding what to test next, work through this priority stack:
| Priority | Target | Coverage Goal |
|---|
| 🔴 1 | Critical business logic (domain constraints, RBAC, payments) | 90%+ |
| 🟠 2 | Complex algorithms and state machines (workflows, state transitions) | 85%+ |
| 🟡 3 | Edge cases that have previously caused bugs (see golden_dataset.yaml) | 100% of known cases |
| 🔵 4 | Project invariant checks (no broad except, UoW commit, tenant isolation) | All new services |
| 🟢 5 | Public API surfaces (FastAPI routers) | 80%+ |
| 💡 6 | Utility and helper functions | 70%+ |
Overall target: ≥80% coverage on src/ ([BUSINESS_RULE_PLACEHOLDER]). Coverage below 70% blocks merge.
Factory Pattern
Use simple factory functions to create consistent test data without duplication.
from pydantic import BaseModel
from src.domain.enums import UserRole
class User(BaseModel):
id: int
name: str
role: UserRole
def get_mock_user(**overrides) -> User:
defaults = {
"id": 1,
"name": "John Doe",
"role": UserRole.MEMBER,
}
return User(**{**defaults, **overrides})
def test_admin_access_allowed():
user = get_mock_user(role=UserRole.ADMIN)
assert user.role == UserRole.ADMIN
Tip: For complex SQLAlchemy models with relationships, use factory_boy
to manage persistence and FK constraints automatically.
Stateful Testing with FakeUnitOfWork
Prefer FakeUnitOfWork over deep repository mocking for service-layer tests.
This verifies actual state transitions rather than mock call counts.
from tests.utils.fake_unit_of_work import FakeUnitOfWork
from src.application.services.order_service import OrderService
from types import SimpleNamespace
from datetime import datetime, timezone, timedelta
future_date = datetime.now(timezone.utc) + timedelta(hours=3)
def test_cancel_order_success():
uow = FakeUnitOfWork()
uow.orders.entities[1] = SimpleNamespace(id=1, schedule_time=future_date)
uow.orders.create_order(SimpleNamespace(order_id=1, customer_id=101))
service = OrderService(uow=uow)
service.cancel_order(order_id=1)
assert uow.orders.get_order_by_id(1).status == "cancelled"
assert uow.committed is True
Always assert uow.committed is True after a successful write operation.
A service that fails to commit is silently discarding work.
Mocking Patterns
Use unittest.mock / pytest-mock to isolate tests from external dependencies.
def test_order_service_sends_confirmation_email(mocker):
mock_bus = mocker.MagicMock()
uow = FakeUnitOfWork()
service = OrderService(uow=uow, bus=mock_bus)
service.create_order(OrderCreate(email="customer@example.com", ...))
mock_bus.publish.assert_called_once()
event = mock_bus.publish.call_args[0][0]
assert event.customer_email == "customer@example.com"
Rules:
- Mock external dependencies (email, payment gateway, S3) — not the code under test.
- Use
FakeUnitOfWork rather than mocking individual repositories.
- Verify mock interactions only when the interaction itself is the behaviour being tested.
- Env var mocking must always use
patch.dict('os.environ', {...}), never patch os.environ.get directly to avoid intercepting legitimate env lookups.
Tenant Isolation in Integration Tests
All integration tests that write to the database must use a scoped tenant_id
to prevent cross-tenant data leakage between test cases.
@pytest.fixture
def test_tenant(db_session):
tenant = Tenant(name="Test Tenant", business_id=1)
db_session.add(tenant)
db_session.commit()
return tenant
@pytest.fixture
def scoped_uow(db_session, test_tenant):
"""UnitOfWork pre-scoped to the test tenant."""
uow = UnitOfWork(db_session)
uow.set_tenant_context(test_tenant.id)
return uow
Never share tenant_id=1 across all tests — isolation prevents false passes
caused by data left behind by a previous test.
Testing Business Invariants
Key project invariants that must have dedicated tests:
def test_create_entity_raises_when_conflict_exists():
uow = FakeUnitOfWork()
uow.entities.entities[1] = active_entity_fixture()
service = EntityService(uow=uow)
with pytest.raises(EntityConflictError):
service.create_entity(conflicting_entity_data())
def test_transaction_stores_precise_value():
tx = TransactionCreate(amount_cents=1999, ...)
assert isinstance(tx.amount_cents, int)
assert tx.amount_cents == 1999
def test_delete_entity_sets_is_deleted_flag():
uow = FakeUnitOfWork()
uow.entities.entities[1] = entity_fixture()
service = EntityService(uow=uow)
service.delete_entity(1)
assert uow.entities.entities[1].is_deleted is True
def test_endpoint_returns_403_for_insufficient_role(client, token):
response = client.delete("/entities/1", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 403
def test_service_does_not_swallow_infrastructure_error(mocker):
uow = FakeUnitOfWork()
uow.entities.get_by_id = mocker.MagicMock(side_effect=DatabaseError("db down"))
service = EntityService(uow=uow)
with pytest.raises(Exception):
service.get_entity(1)
Mutation Testing (High-Confidence QA)
Mutation testing verifies that tests actually catch bugs. Surviving mutants
indicate weak assertions.
Tool: mutmut
mutmut run --paths-to-mutate src/application/services/some_service.py
mutmut results
mutmut show 42
Target: mutation score ≥ 80% on src/application/services/.
If a mutant survives, write a new test case specifically targeting the
mutated logic before marking the feature complete.
Test Structure (Pytest)
import pytest
from tests.utils.fake_unit_of_work import FakeUnitOfWork
class TestUserService:
@pytest.fixture(autouse=True)
def setup(self):
self.uow = FakeUnitOfWork()
self.service = UserService(uow=self.uow)
def test_get_user_not_found_raises_error(self):
self.uow.users.entities = {}
with pytest.raises(UserNotFoundError):
self.service.get_user(999)
def test_create_user_commits_and_returns_dto(self):
result = self.service.create_user(
UserCreate(first_name="Jo", last_name="Doe", email="jo@example.com", ...)
)
assert result.email == "jo@example.com"
assert self.uow.committed is True
Test Type Selection
| Type | When to use | Speed |
|---|
| Unit (FakeUoW) | Service-layer logic, business rules, state transitions | Fast |
| Unit (mock) | Adapter boundaries (email, payment gateway, S3) | Fast |
| Integration | Repository → DB round-trips, Alembic migrations, multi-service flows | Medium |
| API / TestClient | FastAPI router wiring, auth header propagation, status codes | Medium |
| BDD / Gherkin | High-value user journeys (checkout flow, user signup) | Medium |
| Performance | p95 latency benchmarks (≥ weekly, not on every commit) | Slow |
Best Practices
- Arrange-Act-Assert — keep steps visually distinct.
- One behaviour per test — avoid god tests that assert many unrelated things.
- Stateful verification — prefer
FakeUnitOfWork state assertions over mock call counts.
- Always assert
uow.committed after write operations.
- No
time.sleep() — use anyio timeouts or condition-based waiting (see systematic-debugging/condition-based-waiting.md).
- No production data — use factories and fixtures; never seed from a real database.
- Enum types in factories — use
UserRole.MEMBER, not "member".
- Mutation score target — ≥80% on critical services.
Running Tests
pytest
pytest --cov=src --cov-report=term-missing
pytest tests/unit/services/test_user_service.py -v
mutmut run --paths-to-mutate src/application/services/some_service.py