Testing Conventions — GrinSystem API¶
Claude Code: Read this file in full before writing any test. It is the single authoritative source for test structure, factory patterns, class organisation, markers, and examples.
1. Overview & Philosophy¶
Tests mirror the 4-layer DDD architecture. Each layer is tested in isolation with the minimum infrastructure needed for that layer. Never reach for a real database when an in-memory implementation suffices; never spin up HTTP when a direct handler call will do.
┌─────────────────────────────────────┐
│ API Integration Tests │ full HTTP + real Postgres
│ tests/integration/{module}/api/ │ (fewest, slowest)
├─────────────────────────────────────┤
│ Infrastructure Integration Tests │ real Postgres, no HTTP
│ tests/integration/{module}/infra/ │
├─────────────────────────────────────┤
│ Application Unit Tests │ in-memory repos, no I/O
│ tests/unit/{module}/application/ │
├─────────────────────────────────────┤
│ Domain Unit Tests │ pure Python, zero I/O
│ tests/unit/{module}/domain/ │ (most, fastest)
└─────────────────────────────────────┘
| Layer | Tests what | Uses DB? | Uses HTTP? |
|---|---|---|---|
| Domain unit | entities, value objects, events, exceptions | No | No |
| Application unit | command/query handlers | No | No |
| Infrastructure integration | SQLAlchemy repositories | Yes | No |
| API integration | HTTP endpoints end-to-end | Yes | Yes |
Coverage targets:
- Domain unit: 100 % of value object validators, exception HTTP codes, event topic mappings, every state-machine invariant raised by entity methods (happy + sad paths).
- Application unit: 100 % of handler happy paths + every named exception branch the handler itself raises (actor-aware authorization, cross-aggregate guards). Do NOT re-assert state-machine invariants the entity already covers — that duplicates a domain test and couples the handler suite to entity internals. The one exception is an error-precedence test when a handler short-circuits on state before calling expensive ports (see
conventions.md§ Guard Placement); assert the ordering, not the invariant itself. - Infrastructure integration: save/get round-trip, tenant isolation, optimistic lock per repo.
- API integration: 201/200, 404, 409, 422, idempotency re-use per endpoint.
2. Test Directory Layout¶
tests/
├── conftest.py # root fixtures + pytest_collection_modifyitems
├── factories/
│ ├── base.py # BaseSQLAlchemyModelFactory
│ └── modules/
│ └── {module}/
│ ├── domain.py # make_*() domain entity builders
│ ├── models.py # Factory-Boy SQLAlchemy model factories
│ └── schemas.py # Polyfactory Pydantic schema factories
├── unit/
│ └── modules/
│ └── {module}/
│ ├── conftest.py # InMemory*Repository impls + fixtures
│ ├── domain/
│ │ └── test_{entity}.py
│ └── application/
│ └── test_{command_or_query}.py
└── integration/
└── modules/
└── {module}/
├── infrastructure/
│ └── test_{entity}_repository.py
└── api/
└── test_{resource}.py
3. pytest Markers¶
Four marker axes are auto-applied by pytest_collection_modifyitems in tests/conftest.py.
No test file needs to apply markers manually.
| Axis | Values | Applied by |
|---|---|---|
| Speed tier | unit, integration |
directory (tests/unit/, tests/integration/) |
| DDD layer | domain, application, infrastructure, api_layer |
subdirectory |
| Module | item_catalog, identity, manufacturing, … |
subdirectory |
# tests/conftest.py — auto-marker hook
import pytest
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
for item in items:
path = str(item.fspath)
# ── Speed tier ──────────────────────────────────────────────────
if "/tests/unit/" in path:
item.add_marker(pytest.mark.unit)
elif "/tests/integration/" in path:
item.add_marker(pytest.mark.integration)
# ── DDD layer ───────────────────────────────────────────────────
if "/domain/" in path:
item.add_marker(pytest.mark.domain)
elif "/application/" in path:
item.add_marker(pytest.mark.application)
elif "/infrastructure/" in path:
item.add_marker(pytest.mark.infrastructure)
elif "/api/" in path:
item.add_marker(pytest.mark.api_layer)
# ── Module ──────────────────────────────────────────────────────
for module in (
"item_catalog", "identity", "manufacturing",
"warehouse", "traceability", "crm", "invoicing",
):
if f"/{module}/" in path:
item.add_marker(getattr(pytest.mark, module))
break
# pyproject.toml [tool.pytest.ini_options]
markers = [
"unit: no I/O — fast",
"integration: requires docker-compose Postgres",
"domain: domain entity / value-object tests",
"application: command / query handler tests",
"infrastructure: SQLAlchemy repository tests",
"api_layer: HTTP endpoint tests",
"item_catalog",
"identity",
"manufacturing",
"warehouse",
"traceability",
"crm",
"invoicing",
]
Useful invocations:
make test # all layers
pytest -m unit # domain + application only, no DB
pytest -m integration # infra + api, requires docker-compose up
pytest -m "domain or application" # all unit tests, long form
pytest -m item_catalog # every test in the item_catalog module
pytest -m "item_catalog and integration" # item_catalog integration only
pytest -m "domain and not item_catalog" # domain tests for all other modules
4. Test Class Organisation¶
Rule: group all tests for one unit of behaviour in a class.
A unit of behaviour is one command handler, one query handler, one repository method, or one
API endpoint. This keeps related assertions together, enables shared fixtures via @pytest.fixture
on the class, and makes pytest -k TestCreateItem immediately useful.
class TestCreateItem: ← one command handler
def test_persists_to_repo
def test_raises_on_duplicate_code
def test_raises_when_group_not_found
class TestDeactivateItem: ← another command handler
def test_sets_status_to_inactive
def test_raises_when_already_inactive
class TestGetItemById: ← query handler
def test_returns_dto
def test_raises_when_not_found
Class-level fixtures (defined as @pytest.fixture inside the class) are automatically scoped
to that class. Use this for handler/repo/client instances shared across all tests in the class.
class TestCreateItem:
@pytest.fixture
def handler(self, item_repo, item_group_repo):
return CreateItemHandler(item_repo=item_repo, item_group_repo=item_group_repo)
async def test_persists_to_repo(self, handler, item_repo):
...
async def test_raises_on_duplicate_code(self, handler, item_repo):
...
Avoid one-test classes. If a command has only one meaningful test right now, still use a class — it signals that tests belong to the same handler and will grow.
5. Parametrize Related Test Variants¶
When you find yourself writing the third near-identical test that differs only in input data,
collapse them into one @pytest.mark.parametrized test. Parametrization is a first-class
tool here, not an afterthought — keep the test surface small enough to scan, while still
exercising every branch.
When to parametrize¶
Use @pytest.mark.parametrize when the assertion logic is identical across cases and
only the inputs (or the expected values) differ. Each case must remain meaningful in
isolation — a parametrized test with five cases reads like five tests in one place.
| Good fit | Parametrize |
|---|---|
| "Each immutable field rejects the edit with the same status + code" | ("field, value", [...]) |
| "Each role with permission X gets 200; viewer gets 403" | ("role, status, error_code", [...]) |
"Sending old/new EAN combinations produces the right old_ean/new_ean payload" |
("initial, sent, expected_old, expected_new", [...]) |
| "Each enum value round-trips through the API" | ("enum_value", [...]) |
| Poor fit | Keep separate |
|---|---|
| Cases need different fixtures or different setup steps | Separate methods |
| Each case asserts a fundamentally different invariant | Separate methods (the test name is the documentation) |
Value types differ enough that the assertion needs an if/elif chain |
Separate methods |
| Only two cases — no readability win | Two methods is fine |
How to write one¶
Always pass ids=[...] so failures point at the meaningful case name, not parametrized[0].
Use a comma-separated argnames string with a list of tuples — the project's existing style
in tests/unit/modules/item_catalog/application/test_create_item_handler.py is the
reference shape.
@pytest.mark.parametrize(
"field, sent_value",
[
("code", "NEW-CODE"),
("item_type", "product"),
("storage_unit", "piece"),
],
ids=["code", "item_type", "storage_unit"],
)
async def test_returns_400_when_immutable_field_sent(
self,
auth_client: AuthClientFactory,
db_session: AsyncSession,
field: str,
sent_value: str,
) -> None:
"""Sending any immutable field triggers 400 IMMUTABLE_FIELD_CANNOT_BE_CHANGED."""
item = await ItemModelFactory.async_create(db_session)
client = await auth_client(roles=["manager"])
response = await client.patch(
f"/api/v1/items/{item.id}",
json={field: sent_value},
headers={"Idempotency-Key": _key()},
)
assert response.status_code == 400
assert response.json()["error"]["code"] == "IMMUTABLE_FIELD_CANNOT_BE_CHANGED"
Parametrizing across both happy and failure paths¶
When a single behaviour has both passing and failing cases (e.g. role authorisation), put the expected status and error code in the parametrize tuple. One test method covers all paths and the test names tell the whole story:
@pytest.mark.parametrize(
"role, expected_status, expected_error_code",
[
("manager", 200, None),
("technologist", 200, None),
("admin", 200, None),
("viewer", 403, "FORBIDDEN_INSUFFICIENT_PERMISSION"),
],
ids=["manager-allowed", "technologist-allowed", "admin-allowed", "viewer-rejected"],
)
async def test_role_can_or_cannot_edit_item(
self,
auth_client: AuthClientFactory,
db_session: AsyncSession,
role: str,
expected_status: int,
expected_error_code: str | None,
) -> None:
"""ITEM_UPDATE permission gates the endpoint to manager/technologist/admin."""
item = await ItemModelFactory.async_create(db_session)
client = await auth_client(roles=[role])
response = await client.patch(
f"/api/v1/items/{item.id}",
json={"name": "New"},
headers={"Idempotency-Key": _key()},
)
assert response.status_code == expected_status, response.text
if expected_error_code is not None:
assert response.json()["error"]["code"] == expected_error_code
Heuristic: the "rule of three"¶
When you write the third near-identical test, stop and parametrize. Two repeats are fine — the third is the signal that the duplication will keep growing as the surface area expands. A single parametrized test of three cases compresses to ~15 lines and still gives each case its own pytest node id; three separate methods would be ~45 lines saying the same thing.
Anti-pattern: hidden conditionals¶
If a parametrized test grows an if branch in its assertion section that fires for some
cases and not others, you've combined two distinct behaviours into one test — split it back
out. Parametrize is for the same test, run with different data, not two different tests
sharing a method name.
6. Factory Conventions¶
Three factory types serve three distinct purposes. Never mix them.
| Factory type | Location | Library | Produces | Used in |
|---|---|---|---|---|
| Domain factory | tests/factories/modules/{m}/domain.py |
none (hand-rolled) | Domain entity | unit tests |
| Model factory | tests/factories/modules/{m}/models.py |
Factory-Boy | SQLAlchemy model | integration tests |
| Schema factory | tests/factories/modules/{m}/schemas.py |
Polyfactory | Pydantic schema | API integration tests |
6a. Domain Factories — make_*() functions¶
When: building pure-Python domain entities / aggregates for unit tests. Why hand-rolled: domain constructors have real business constraints; auto-generated random values would produce test data with meaningless or invalid defaults.
# tests/factories/modules/item_catalog/domain.py
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from src.modules.item_catalog.domain.entities.item import Item
from src.modules.item_catalog.domain.entities.item_group import ItemGroup
from src.modules.item_catalog.domain.value_objects import ItemStatus, ItemType, StorageUnit
from src.shared.ids import new_id
TEST_TENANT_ID = UUID("00000000-0000-0000-0000-00000000aaaa")
TEST_USER_ID = UUID("00000000-0000-0000-0000-00000000bbbb")
def make_item(
*,
item_id: UUID | None = None,
code: str = "TEST-001",
name: str = "Test Item",
item_type: ItemType = ItemType.INGREDIENT,
storage_unit: StorageUnit = StorageUnit.KG,
status: ItemStatus = ItemStatus.ACTIVE,
is_system: bool = False,
tenant_id: UUID = TEST_TENANT_ID,
created_by: UUID = TEST_USER_ID,
) -> Item:
"""Build an Item domain entity with sensible defaults. Does not touch the DB."""
return Item(
id=item_id or new_id(),
tenant_id=tenant_id,
code=code,
name=name,
item_type=item_type,
storage_unit=storage_unit,
status=status,
is_system=is_system,
created_at=datetime.now(tz=timezone.utc),
created_by=created_by,
)
def make_item_group(
*,
group_id: UUID | None = None,
name: str = "Test Group",
item_type: ItemType = ItemType.INGREDIENT,
tenant_id: UUID = TEST_TENANT_ID,
created_by: UUID = TEST_USER_ID,
) -> ItemGroup:
"""Build an ItemGroup domain entity with sensible defaults. Does not touch the DB."""
return ItemGroup(
id=group_id or new_id(),
tenant_id=tenant_id,
name=name,
item_type=item_type,
created_at=datetime.now(tz=timezone.utc),
created_by=created_by,
)
Rules:
- One
make_*()per aggregate root / entity. - All parameters are keyword-only (
*). - Defaults must be valid values that satisfy domain invariants.
- Use the fixed
TEST_TENANT_ID/TEST_USER_IDconstants — never callnew_id()for them. - Never persist to the DB from a domain factory.
6b. Model Factories (Factory-Boy) — *ModelFactory¶
When: seeding the real Postgres DB in infrastructure and API integration tests.
Base class: BaseSQLAlchemyModelFactory from tests/factories/base.py.
BaseSQLAlchemyModelFactory¶
# tests/factories/base.py
from __future__ import annotations
from typing import Any
import factory
from sqlalchemy.ext.asyncio import AsyncSession
class BaseSQLAlchemyModelFactory(factory.Factory):
"""
Base for all SQLAlchemy ORM model factories.
.build(**kwargs) — instantiate ORM model, no DB
.async_create(session, **kwargs) — flush into the session's transaction
.async_create_batch(session, n, **kwargs) — flush n rows
"""
class Meta:
abstract = True
@classmethod
def build(cls, **kwargs: Any) -> Any:
"""Build without persisting. Use in unit tests or for mapping assertions."""
return super().build(**kwargs)
@classmethod
async def async_create(cls, session: AsyncSession, **kwargs: Any) -> Any:
"""Add to session and flush. Stays inside the test transaction (rolls back at teardown)."""
obj = cls.build(**kwargs)
session.add(obj)
await session.flush()
return obj
@classmethod
async def async_create_batch(
cls, session: AsyncSession, size: int, **kwargs: Any
) -> list[Any]:
return [await cls.async_create(session, **kwargs) for _ in range(size)]
Factory structure and style¶
Every factory must follow this attribute order and section grouping — matching the order of columns in the corresponding SQLAlchemy model:
# tests/factories/modules/item_catalog/models.py
from __future__ import annotations
import factory
from src.modules.item_catalog.domain.value_objects import ItemStatus, ItemType, StorageUnit
from src.modules.item_catalog.infrastructure.models.item import ItemModel
from src.modules.item_catalog.infrastructure.models.item_group import ItemGroupModel
from src.shared.ids import new_id
from tests.factories.base import BaseSQLAlchemyModelFactory
from tests.factories.modules.item_catalog.domain import TEST_TENANT_ID, TEST_USER_ID
class ItemModelFactory(BaseSQLAlchemyModelFactory):
"""Factory for the ItemModel ORM model."""
class Meta:
model = ItemModel
# ── Primary key ────────────────────────────────────────────────────────
id = factory.LazyFunction(new_id) # UUIDv7; never use a static UUID for PK
# ── Tenant scope ───────────────────────────────────────────────────────
tenant_id = TEST_TENANT_ID
# ── Base attributes (same order as model columns) ──────────────────────
code = factory.Sequence(lambda n: f"ITEM-{n:04d}") # unique per-tenant
name = factory.Faker("word")
item_type = ItemType.INGREDIENT.value
storage_unit = StorageUnit.KG.value
status = ItemStatus.ACTIVE.value
is_system = False
description = None # nullable — off by default
tags = factory.LazyFunction(list)
shelf_life_days = None
ean = None
# ── Relationship FKs (decoupled — valid without persisting related object) ──
item_group_id = None
# ── Audit fields ───────────────────────────────────────────────────────
created_by = TEST_USER_ID
version = 0
# ── Optional attributes and relationships ─────────────────────────────
class Params:
# Nullable field traits
with_description = factory.Trait(description=factory.Faker("sentence"))
with_shelf_life = factory.Trait(
shelf_life_days=factory.Faker("pyint", min_value=1, max_value=365)
)
with_ean = factory.Trait(ean=factory.Sequence(lambda n: f"590{n:010d}"))
# Relationship traits
with_item_group = factory.Trait(
item_group=factory.SubFactory(
"tests.factories.modules.item_catalog.models.ItemGroupModelFactory",
# Prevent recursion: do not create items back from the group
with_items=False,
)
)
# Composite convenience traits
with_all_nullables = factory.Trait(with_description=True, with_shelf_life=True, with_ean=True)
with_all_relationships = factory.Trait(with_item_group=True)
with_all = factory.Trait(with_all_nullables=True, with_all_relationships=True)
@factory.post_generation
def _after_generate(self, create: bool, extracted: object, **kwargs: object) -> None:
"""Sync FK IDs from related objects after generation."""
if hasattr(self, "item_group") and self.item_group is not None:
self.item_group_id = self.item_group.id
class ItemGroupModelFactory(BaseSQLAlchemyModelFactory):
"""Factory for the ItemGroupModel ORM model."""
class Meta:
model = ItemGroupModel
# ── Primary key ────────────────────────────────────────────────────────
id = factory.LazyFunction(new_id)
# ── Tenant scope ───────────────────────────────────────────────────────
tenant_id = TEST_TENANT_ID
# ── Base attributes ────────────────────────────────────────────────────
name = factory.Sequence(lambda n: f"Group {n}")
item_type = ItemType.INGREDIENT.value
# ── Audit fields ───────────────────────────────────────────────────────
created_by = TEST_USER_ID
version = 0
Relationship patterns¶
One-to-Many (parent holds a collection): Use Trait + RelatedFactoryVariableList
on the parent side; use SubFactory on the child (FK) side.
# On the "many" / child side (Item belongs to ItemGroup):
class Params:
with_item_group = factory.Trait(
item_group=factory.SubFactory(
"tests.factories.modules.item_catalog.models.ItemGroupModelFactory",
with_items=False, # break recursion
)
)
# On the "one" / parent side (ItemGroup has many Items) — if that relationship exists:
class Params:
with_items = factory.Trait(
items=RelatedFactoryVariableList(
"tests.factories.modules.item_catalog.models.ItemModelFactory",
factory_related_name="item_group",
size=2, # default; override at call site: with_items__size=5
)
)
Self-referential (e.g., parent ItemGroup): Break the recursion by setting
the nested relationship to None in the SubFactory:
class Params:
with_parent_group = factory.Trait(
parent_group=factory.SubFactory(
"tests.factories.modules.item_catalog.models.ItemGroupModelFactory",
parent_group=None, # ← stops recursion at one level
)
)
Post-generation FK sync: Always define _after_generate when using SubFactory
so the FK column is aligned with the created object's PK. SQLAlchemy does not do this
automatically for plain column values:
@factory.post_generation
def _after_generate(self, create, extracted, **kwargs):
if hasattr(self, "item_group") and self.item_group is not None:
self.item_group_id = self.item_group.id
Model factory style rules¶
- Naming:
{ModelName}Factory. One class per SQLAlchemy model. - Docstring: one-line description of what model the factory produces.
- Attribute order: follows the column order in the SQLAlchemy model definition.
- Section comments:
# Primary key,# Tenant scope,# Base attributes,# Relationship FKs,# Audit fields,# Optional attributes and relationships. - Nullable fields default to
None— factories represent the minimal valid state. Use traits to opt into optional data. - Enum columns store
.value(string), not the enum member — match the column type. factory.Sequencefor unique columns;factory.Fakerfor readable non-unique fields;factory.LazyFunction(new_id)for UUID PKs.- Comment complex lambdas when the logic is non-obvious.
Usage examples¶
# Build only (no DB) — useful for testing column mappings or DTO conversions
item = ItemModelFactory.build(code="SPECIAL-001")
# Create one row flushed into the test transaction
item = await ItemModelFactory.async_create(db_session, code="PERSIST-001")
# Create 5 rows
items = await ItemModelFactory.async_create_batch(db_session, 5, item_type=ItemType.PRODUCT.value)
# Create with trait
item = await ItemModelFactory.async_create(db_session, with_item_group=True)
item = await ItemModelFactory.async_create(db_session, with_all=True)
# Pass an existing related object — FK is synced by _after_generate
group = await ItemGroupModelFactory.async_create(db_session)
item = await ItemModelFactory.async_create(db_session, item_group=group)
assert item.item_group_id == group.id
# Override default collection size
group = await ItemGroupModelFactory.async_create(db_session, with_items=True, with_items__size=5)
6c. Schema Factories (Polyfactory) — *SchemaFactory¶
When: generating valid Pydantic request bodies for API integration tests.
Only use .build(**overrides) — no DB involved.
# tests/factories/modules/item_catalog/schemas.py
from __future__ import annotations
from polyfactory.factories.pydantic_factory import ModelFactory
from src.modules.item_catalog.api.schemas.items import CreateItemRequest, UpdateItemRequest
from src.modules.item_catalog.domain.value_objects import ItemType, StorageUnit
class CreateItemSchemaFactory(ModelFactory[CreateItemRequest]):
"""Factory for the CreateItemRequest Pydantic schema."""
__use_examples__ = True # prefer Field(examples=[...]) values
__allow_none_optionals__ = False # populate optional fields by default
# Pin fields needed for consistent assertions or uniqueness constraints
item_type = ItemType.INGREDIENT
storage_unit = StorageUnit.KG
class UpdateItemSchemaFactory(ModelFactory[UpdateItemRequest]):
"""Factory for the UpdateItemRequest Pydantic schema."""
__use_examples__ = True
__allow_none_optionals__ = False
Usage:
payload = CreateItemSchemaFactory.build()
payload = CreateItemSchemaFactory.build(code="OVERRIDE-001")
json_body = payload.model_dump(mode="json")
7. Shared Fixtures Reference¶
All root-level fixtures live in tests/conftest.py.
db_session — AsyncSession (function-scoped)¶
Wraps every integration test in an open Postgres transaction that always rolls back at
teardown — data never persists between tests. The RLS GUC app.current_tenant is pre-set
to TEST_TENANT_ID.
# tests/conftest.py (excerpt)
import pytest_asyncio
from collections.abc import AsyncIterator
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.pool import NullPool
from src.config import get_settings
from tests.factories.modules.item_catalog.domain import TEST_TENANT_ID
@pytest_asyncio.fixture
async def db_session() -> AsyncIterator[AsyncSession]:
"""One session per test; transaction is always rolled back."""
settings = get_settings()
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool, future=True)
try:
async with engine.connect() as conn:
await conn.begin()
async with AsyncSession(bind=conn, expire_on_commit=False) as session:
await session.execute(
text("SELECT set_config('app.current_tenant', :tenant, true)"),
{"tenant": str(TEST_TENANT_ID)},
)
yield session
await conn.rollback()
finally:
await engine.dispose()
Why a fresh engine per test (not
src.shared.db.session.get_engine())? SQLAlchemy async pooled connections are bound to the event loop that first checked them out;pytest-asynciocreates a new loop per test, so subsequent tests would fail withFuture attached to a different looporanother operation is in progressif they reused the module-cached pool.NullPool+ per-testdispose()sidesteps the cache entirely and keeps each test's connection on its own loop.
client — AsyncClient (function-scoped)¶
HTTPX client wired to the FastAPI ASGI app. Overrides get_session so every request uses
the same db_session — app writes and test assertions share one transaction and both
roll back at teardown.
# tests/conftest.py (excerpt)
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
from src.main import create_app
from src.shared.db.session import get_session
app = create_app()
async def _override_get_session():
yield db_session
app.dependency_overrides[get_session] = _override_get_session
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
yield c
app.dependency_overrides.clear()
8. Domain Unit Tests¶
Location: tests/unit/modules/{module}/domain/
Markers: unit, domain, {module} — auto-applied.
Tools: plain pytest, sync functions preferred, domain factories only.
No DB. No mocking. No HTTP.
What to cover¶
| Target | What to assert |
|---|---|
| Entity field defaults | correct initial values |
| Value object validation | valid inputs accepted; invalid inputs raise ValueError |
| Aggregate domain methods | state transitions, guard conditions, raised exceptions |
| Domain events | module() string, event_type, aggregate_type |
| Exception HTTP codes | .http_status on each exception class |
Example — item_catalog¶
# tests/unit/modules/item_catalog/domain/test_item.py
"""Domain unit tests for the Item aggregate, value objects, events, and exceptions."""
from __future__ import annotations
import pytest
from src.modules.item_catalog.domain.entities.item import Item
from src.modules.item_catalog.domain.events import ItemCreated, ItemDeactivated
from src.modules.item_catalog.domain.exceptions import (
InvalidItemStateError,
ItemCodeAlreadyExistsError,
ItemGroupNotFoundError,
ItemNotFoundError,
)
from src.modules.item_catalog.domain.value_objects import ItemStatus, ItemType, StorageUnit, Tags
from src.shared.ids import new_id
from tests.factories.modules.item_catalog.domain import TEST_TENANT_ID, make_item, make_item_group
class TestItemDefaults:
"""Item aggregate is initialised with the correct default state."""
def test_version_starts_at_zero(self) -> None:
assert make_item().version == 0
def test_tags_default_to_empty(self) -> None:
assert make_item().tags == Tags()
def test_description_defaults_to_none(self) -> None:
assert make_item().description is None
class TestItemCreate:
"""Item.create() validates inputs and returns a correctly initialised aggregate."""
def test_assigns_provided_code(self) -> None:
item = Item.create(
tenant_id=TEST_TENANT_ID,
code="NEW-001",
name="Flour",
item_type=ItemType.INGREDIENT,
storage_unit=StorageUnit.KG,
created_by=new_id(),
)
assert item.code == "NEW-001"
def test_initial_status_is_active(self) -> None:
item = Item.create(
tenant_id=TEST_TENANT_ID,
code="NEW-002",
name="Sugar",
item_type=ItemType.INGREDIENT,
storage_unit=StorageUnit.KG,
created_by=new_id(),
)
assert item.status is ItemStatus.ACTIVE
def test_generates_uuid_id(self) -> None:
item = Item.create(
tenant_id=TEST_TENANT_ID,
code="NEW-003",
name="Salt",
item_type=ItemType.INGREDIENT,
storage_unit=StorageUnit.KG,
created_by=new_id(),
)
assert item.id is not None
class TestItemDeactivate:
"""Item.deactivate() guards and state transitions."""
def test_sets_status_to_inactive(self) -> None:
item = make_item(status=ItemStatus.ACTIVE)
item.deactivate(deactivated_by=new_id())
assert item.status is ItemStatus.INACTIVE
def test_raises_when_already_inactive(self) -> None:
item = make_item(status=ItemStatus.INACTIVE)
with pytest.raises(InvalidItemStateError):
item.deactivate(deactivated_by=new_id())
def test_raises_for_system_item(self) -> None:
item = make_item(is_system=True)
with pytest.raises(InvalidItemStateError):
item.deactivate(deactivated_by=new_id())
class TestItemAssignToGroup:
"""Item.assign_to_group() validates type compatibility."""
def test_assigns_when_types_match(self) -> None:
item = make_item(item_type=ItemType.INGREDIENT)
group = make_item_group(item_type=ItemType.INGREDIENT)
item.assign_to_group(group)
assert item.item_group_id == group.id
def test_raises_when_types_mismatch(self) -> None:
item = make_item(item_type=ItemType.INGREDIENT)
group = make_item_group(item_type=ItemType.PRODUCT)
with pytest.raises(InvalidItemStateError):
item.assign_to_group(group)
class TestItemEvents:
"""Events produced by the item_catalog bounded context."""
def test_item_created_module_segment(self) -> None:
event = ItemCreated(aggregate_id=new_id(), tenant_id=TEST_TENANT_ID)
assert event.module() == "item_catalog"
assert event.event_type == "ItemCreated"
def test_item_deactivated_module_segment(self) -> None:
event = ItemDeactivated(aggregate_id=new_id(), tenant_id=TEST_TENANT_ID)
assert event.module() == "item_catalog"
assert event.event_type == "ItemDeactivated"
class TestItemCatalogExceptions:
"""Each exception carries the correct HTTP status code."""
def test_item_not_found_is_404(self) -> None:
assert ItemNotFoundError("x").http_status == 404
def test_item_group_not_found_is_404(self) -> None:
assert ItemGroupNotFoundError("x").http_status == 404
def test_item_code_already_exists_is_409(self) -> None:
assert ItemCodeAlreadyExistsError("x").http_status == 409
def test_invalid_item_state_is_422(self) -> None:
assert InvalidItemStateError("x").http_status == 422
9. Application Unit Tests¶
Location: tests/unit/modules/{module}/application/
Markers: unit, application, {module} — auto-applied.
Tools: in-memory repos (from tests/unit/modules/{module}/conftest.py), domain factories.
No DB. No HTTP. No mocking framework.
In-memory repositories¶
Every module's tests/unit/modules/{module}/conftest.py must define a dict-backed
InMemory*Repository for each domain repository ABC. These are real implementations of the
ABC contract — not mocks.
# tests/unit/modules/item_catalog/conftest.py
from __future__ import annotations
from uuid import UUID
import pytest
from src.modules.item_catalog.domain.entities.item import Item
from src.modules.item_catalog.domain.entities.item_group import ItemGroup
from src.modules.item_catalog.domain.repositories.item_group_repository import ItemGroupRepository
from src.modules.item_catalog.domain.repositories.item_repository import ItemRepository
# Re-export domain factories for convenience in test files
from tests.factories.modules.item_catalog.domain import ( # noqa: F401
TEST_TENANT_ID,
TEST_USER_ID,
make_item,
make_item_group,
)
class InMemoryItemRepository(ItemRepository):
"""Dict-backed ItemRepository for unit tests — zero SQLAlchemy."""
def __init__(self) -> None:
self._store: dict[UUID, Item] = {}
async def save(self, item: Item) -> None:
self._store[item.id] = item
async def get_by_id(self, item_id: UUID, tenant_id: UUID) -> Item | None:
item = self._store.get(item_id)
if item is None or item.tenant_id != tenant_id:
return None
return item
async def exists_by_code(self, code: str, tenant_id: UUID) -> bool:
return any(
i.code.lower() == code.lower() and i.tenant_id == tenant_id
for i in self._store.values()
)
class InMemoryItemGroupRepository(ItemGroupRepository):
"""Dict-backed ItemGroupRepository for unit tests."""
def __init__(self) -> None:
self._store: dict[UUID, ItemGroup] = {}
async def save(self, item_group: ItemGroup) -> None:
self._store[item_group.id] = item_group
async def get_by_id(self, group_id: UUID, tenant_id: UUID) -> ItemGroup | None:
group = self._store.get(group_id)
if group is None or group.tenant_id != tenant_id:
return None
return group
@pytest.fixture
def item_repo() -> InMemoryItemRepository:
return InMemoryItemRepository()
@pytest.fixture
def item_group_repo() -> InMemoryItemGroupRepository:
return InMemoryItemGroupRepository()
Structure and examples¶
One class per handler. Class fixtures provide the handler instance. Use _make_cmd() helpers
to reduce boilerplate in parametrised tests.
# tests/unit/modules/item_catalog/application/test_create_item.py
"""Unit tests for CreateItemHandler."""
from __future__ import annotations
import pytest
from src.modules.item_catalog.application.commands.create_item import (
CreateItemCommand,
CreateItemHandler,
)
from src.modules.item_catalog.domain.exceptions import (
ItemCodeAlreadyExistsError,
ItemGroupNotFoundError,
)
from src.modules.item_catalog.domain.value_objects import ItemStatus, ItemType, StorageUnit
from src.shared.ids import new_id
from tests.unit.modules.item_catalog.conftest import (
TEST_TENANT_ID,
TEST_USER_ID,
InMemoryItemGroupRepository,
InMemoryItemRepository,
make_item,
make_item_group,
)
class TestCreateItem:
"""CreateItemHandler — happy paths and all failure branches."""
@pytest.fixture
def handler(
self,
item_repo: InMemoryItemRepository,
item_group_repo: InMemoryItemGroupRepository,
) -> CreateItemHandler:
return CreateItemHandler(item_repo=item_repo, item_group_repo=item_group_repo)
def _cmd(self, **overrides: object) -> CreateItemCommand:
return CreateItemCommand(
**{
"tenant_id": TEST_TENANT_ID,
"user_id": TEST_USER_ID,
"code": "NEW-001",
"name": "Flour",
"item_type": ItemType.INGREDIENT,
"storage_unit": StorageUnit.KG,
"idempotency_key": new_id(),
**overrides,
}
)
async def test_persists_item_to_repo(
self,
handler: CreateItemHandler,
item_repo: InMemoryItemRepository,
) -> None:
result = await handler.handle(self._cmd(code="FLOUR-001"))
saved = await item_repo.get_by_id(result.id, TEST_TENANT_ID)
assert saved is not None
assert saved.code == "FLOUR-001"
assert saved.status is ItemStatus.ACTIVE
async def test_returns_item_id_in_dto(self, handler: CreateItemHandler) -> None:
result = await handler.handle(self._cmd())
assert result.id is not None
async def test_raises_when_code_already_taken(
self,
handler: CreateItemHandler,
item_repo: InMemoryItemRepository,
) -> None:
await item_repo.save(make_item(code="TAKEN-001"))
with pytest.raises(ItemCodeAlreadyExistsError):
await handler.handle(self._cmd(code="TAKEN-001"))
async def test_raises_when_item_group_not_found(
self, handler: CreateItemHandler
) -> None:
with pytest.raises(ItemGroupNotFoundError):
await handler.handle(self._cmd(item_group_id=new_id()))
async def test_assigns_to_group_when_provided(
self,
handler: CreateItemHandler,
item_repo: InMemoryItemRepository,
item_group_repo: InMemoryItemGroupRepository,
) -> None:
group = make_item_group(item_type=ItemType.INGREDIENT)
await item_group_repo.save(group)
result = await handler.handle(self._cmd(item_group_id=group.id))
saved = await item_repo.get_by_id(result.id, TEST_TENANT_ID)
assert saved is not None
assert saved.item_group_id == group.id
@pytest.mark.parametrize(
"item_type, storage_unit",
[
(ItemType.INGREDIENT, StorageUnit.KG),
(ItemType.PRODUCT, StorageUnit.PIECE),
(ItemType.PACKAGING, StorageUnit.PIECE),
],
ids=["ingredient", "product", "packaging"],
)
async def test_accepts_valid_type_unit_combinations(
self,
handler: CreateItemHandler,
item_type: ItemType,
storage_unit: StorageUnit,
) -> None:
result = await handler.handle(self._cmd(item_type=item_type, storage_unit=storage_unit))
assert result.id is not None
Note on outbox in unit tests:
InMemoryItemRepositorydoes not track outbox rows. Outbox correctness is verified at the API integration layer only.
10. Infrastructure Integration Tests¶
Location: tests/integration/modules/{module}/infrastructure/
Markers: integration, infrastructure, {module} — auto-applied.
Tools: db_session fixture, model factories, real Postgres.
No HTTP.
What to cover per repository¶
| Scenario | Test name |
|---|---|
| Save + get round-trip | test_returns_item_when_found |
| Tenant isolation | test_returns_none_for_different_tenant |
| Not found | test_returns_none_when_missing |
| Optimistic locking | test_raises_concurrent_modification_on_stale_version |
| List / filter | test_filters_by_item_type |
Example¶
# tests/integration/modules/item_catalog/infrastructure/test_item_repository.py
"""Integration tests for SQLAlchemyItemRepository against real Postgres."""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.modules.item_catalog.domain.value_objects import ItemType
from src.modules.item_catalog.infrastructure.repositories.sqlalchemy_item_repository import (
SQLAlchemyItemRepository,
)
from src.shared.exceptions import ConcurrentModificationError
from src.shared.ids import new_id
from tests.factories.modules.item_catalog.domain import TEST_TENANT_ID
from tests.factories.modules.item_catalog.models import ItemGroupModelFactory, ItemModelFactory
class TestGetById:
"""get_by_id — happy path, not-found, tenant isolation."""
async def test_returns_item_when_found(self, db_session: AsyncSession) -> None:
model = await ItemModelFactory.async_create(db_session, code="REPO-001")
repo = SQLAlchemyItemRepository(db_session)
result = await repo.get_by_id(model.id, TEST_TENANT_ID)
assert result is not None
assert result.code == "REPO-001"
async def test_returns_none_when_missing(self, db_session: AsyncSession) -> None:
repo = SQLAlchemyItemRepository(db_session)
assert await repo.get_by_id(new_id(), TEST_TENANT_ID) is None
async def test_returns_none_for_different_tenant(self, db_session: AsyncSession) -> None:
model = await ItemModelFactory.async_create(db_session, tenant_id=new_id())
repo = SQLAlchemyItemRepository(db_session)
assert await repo.get_by_id(model.id, TEST_TENANT_ID) is None
class TestSave:
"""save — persistence and optimistic locking."""
async def test_updated_field_survives_refetch(self, db_session: AsyncSession) -> None:
model = await ItemModelFactory.async_create(db_session)
repo = SQLAlchemyItemRepository(db_session)
item = await repo.get_by_id(model.id, TEST_TENANT_ID)
assert item is not None
item.name = "Updated Name"
await repo.save(item)
await db_session.flush()
refreshed = await repo.get_by_id(model.id, TEST_TENANT_ID)
assert refreshed is not None
assert refreshed.name == "Updated Name"
async def test_raises_concurrent_modification_on_stale_version(
self, db_session: AsyncSession
) -> None:
model = await ItemModelFactory.async_create(db_session)
repo = SQLAlchemyItemRepository(db_session)
item = await repo.get_by_id(model.id, TEST_TENANT_ID)
assert item is not None
item.version = 999 # deliberately stale
with pytest.raises(ConcurrentModificationError):
await repo.save(item)
await db_session.flush()
class TestListByTenant:
"""list_by_tenant — filtering and isolation."""
async def test_excludes_other_tenant_rows(self, db_session: AsyncSession) -> None:
await ItemModelFactory.async_create_batch(db_session, 3, tenant_id=TEST_TENANT_ID)
await ItemModelFactory.async_create_batch(db_session, 2, tenant_id=new_id())
repo = SQLAlchemyItemRepository(db_session)
results = await repo.list_by_tenant(TEST_TENANT_ID)
assert len(results) == 3
async def test_filters_by_item_type(self, db_session: AsyncSession) -> None:
await ItemModelFactory.async_create(db_session, item_type=ItemType.INGREDIENT.value)
await ItemModelFactory.async_create(db_session, item_type=ItemType.PRODUCT.value)
repo = SQLAlchemyItemRepository(db_session)
results = await repo.list_by_tenant(TEST_TENANT_ID, item_type=ItemType.INGREDIENT)
assert all(r.item_type is ItemType.INGREDIENT for r in results)
11. API Integration Tests¶
Location: tests/integration/modules/{module}/api/
Markers: integration, api_layer, {module} — auto-applied.
Tools: client + db_session fixtures, schema factories for request bodies, model
factories for DB pre-seeding.
Mandatory: assert outbox rows after every write¶
Every test for a command endpoint must assert that the correct OutboxEvent row was
written. This verifies the transactional outbox without needing the dispatcher to run — the end-to-end publish → poll → dispatch path is covered separately by the outbox_dispatcher integration tests (GRI-116).
What to cover per endpoint¶
| Scenario | Expected status |
|---|---|
| Successful create | 201 + DB row + outbox row |
| Successful read | 200 + correct response body |
| Not found | 404 |
| Code/name conflict | 409 |
| Missing required field | 422 |
| Idempotency key replay | 201, same id returned |
Example¶
# tests/integration/modules/item_catalog/api/test_items.py
"""API integration tests for /api/v1/items."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.modules.item_catalog.infrastructure.models.item import ItemModel
from src.shared.ids import new_id
from src.shared.outbox.models import OutboxEvent
from tests.factories.modules.item_catalog.domain import TEST_TENANT_ID
from tests.factories.modules.item_catalog.models import ItemModelFactory
from tests.factories.modules.item_catalog.schemas import CreateItemSchemaFactory
class TestCreateItem:
"""POST /api/v1/items."""
async def test_returns_201_and_creates_db_row(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
payload = CreateItemSchemaFactory.build()
resp = await client.post(
"/api/v1/items",
json=payload.model_dump(mode="json"),
headers={"Idempotency-Key": str(new_id())},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"] == payload.code
row = await db_session.get(ItemModel, body["id"])
assert row is not None
async def test_writes_item_created_outbox_event(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
payload = CreateItemSchemaFactory.build()
resp = await client.post(
"/api/v1/items",
json=payload.model_dump(mode="json"),
headers={"Idempotency-Key": str(new_id())},
)
assert resp.status_code == 201
item_id = resp.json()["id"]
outbox = (
await db_session.execute(
select(OutboxEvent).where(OutboxEvent.aggregate_id == item_id)
)
).scalars().all()
assert len(outbox) == 1
assert outbox[0].event_type == "ItemCreated"
async def test_returns_422_on_missing_required_fields(
self, client: AsyncClient
) -> None:
resp = await client.post(
"/api/v1/items", json={}, headers={"Idempotency-Key": str(new_id())}
)
assert resp.status_code == 422
async def test_returns_409_on_duplicate_code(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
await ItemModelFactory.async_create(db_session, code="DUP-001")
payload = CreateItemSchemaFactory.build(code="DUP-001")
resp = await client.post(
"/api/v1/items",
json=payload.model_dump(mode="json"),
headers={"Idempotency-Key": str(new_id())},
)
assert resp.status_code == 409
async def test_is_idempotent_on_key_replay(
self, client: AsyncClient
) -> None:
payload = CreateItemSchemaFactory.build()
key = str(new_id())
headers = {"Idempotency-Key": key}
resp1 = await client.post("/api/v1/items", json=payload.model_dump(mode="json"), headers=headers)
resp2 = await client.post("/api/v1/items", json=payload.model_dump(mode="json"), headers=headers)
assert resp1.status_code == 201
assert resp2.status_code == 201
assert resp1.json()["id"] == resp2.json()["id"]
class TestGetItem:
"""GET /api/v1/items/{id}."""
async def test_returns_200_with_correct_body(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
model = await ItemModelFactory.async_create(db_session, code="FETCH-001")
resp = await client.get(f"/api/v1/items/{model.id}")
assert resp.status_code == 200
assert resp.json()["code"] == "FETCH-001"
async def test_returns_404_when_missing(self, client: AsyncClient) -> None:
assert (await client.get(f"/api/v1/items/{new_id()}")).status_code == 404
async def test_returns_404_for_other_tenant(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
model = await ItemModelFactory.async_create(db_session, tenant_id=new_id())
assert (await client.get(f"/api/v1/items/{model.id}")).status_code == 404
class TestDeactivateItem:
"""POST /api/v1/items/{id}/deactivate."""
async def test_returns_200_and_writes_outbox(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
model = await ItemModelFactory.async_create(db_session)
resp = await client.post(
f"/api/v1/items/{model.id}/deactivate",
headers={"Idempotency-Key": str(new_id())},
)
assert resp.status_code == 200
outbox = (
await db_session.execute(
select(OutboxEvent).where(OutboxEvent.aggregate_id == str(model.id))
)
).scalars().all()
assert any(e.event_type == "ItemDeactivated" for e in outbox)
async def test_returns_422_when_already_inactive(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
model = await ItemModelFactory.async_create(db_session, status="inactive")
resp = await client.post(
f"/api/v1/items/{model.id}/deactivate",
headers={"Idempotency-Key": str(new_id())},
)
assert resp.status_code == 422
12. Checklist: Adding Tests for a New Module¶
Follow this order when implementing tests for a new module or a new use-case ticket.
- [ ]
tests/factories/modules/{module}/domain.py—make_*()per aggregate root - [ ]
tests/factories/modules/{module}/models.py—*ModelFactoryper SQLAlchemy model - [ ]
tests/factories/modules/{module}/schemas.py—*SchemaFactoryper API request schema - [ ]
tests/unit/modules/{module}/conftest.py—InMemory*Repository+ fixtures; re-export domain factories - [ ]
tests/unit/modules/{module}/domain/test_{entity}.py— classes:TestEntityDefaults,TestEntityCreate,TestEntityMethod,TestEntityEvents,TestEntityExceptions - [ ]
tests/unit/modules/{module}/application/test_{command}.py— oneTest{CommandName}class per handler - [ ]
tests/integration/modules/{module}/infrastructure/test_{entity}_repository.py— classes:TestGetById,TestSave,TestList - [ ]
tests/integration/modules/{module}/api/test_{resource}.py— classes:TestCreate{Resource},TestGet{Resource},TestUpdate{Resource},TestDelete{Resource} - [ ]
pytest -m unitpasses with no DB - [ ]
pytest -m integrationpasses against docker-compose Postgres - [ ]
pytest -m {module}runs all module tests cleanly - [ ]
make lint-checkandmake type-checkpass
13. What NOT to Test¶
- SQLAlchemy internals (session lifecycle, engine pooling)
- FastAPI routing mechanics (just verify routers are mounted at integration level)
- Pydantic serialisation (trust the library)
- Alembic migrations (verified by
make migrate-upin CI) - The in-process dispatcher's own poll/claim machinery (covered by the
outbox_dispatcherintegration tests, GRI-116 — individual handler tests assert outbox rows, not the relay) - Trivial getters/setters with no logic
- Framework-generated code