Skip to content

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.


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_ID constants — never call new_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

  1. Naming: {ModelName}Factory. One class per SQLAlchemy model.
  2. Docstring: one-line description of what model the factory produces.
  3. Attribute order: follows the column order in the SQLAlchemy model definition.
  4. Section comments: # Primary key, # Tenant scope, # Base attributes, # Relationship FKs, # Audit fields, # Optional attributes and relationships.
  5. Nullable fields default to None — factories represent the minimal valid state. Use traits to opt into optional data.
  6. Enum columns store .value (string), not the enum member — match the column type.
  7. factory.Sequence for unique columns; factory.Faker for readable non-unique fields; factory.LazyFunction(new_id) for UUID PKs.
  8. 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_sessionAsyncSession (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-asyncio creates a new loop per test, so subsequent tests would fail with Future attached to a different loop or another operation is in progress if they reused the module-cached pool. NullPool + per-test dispose() sidesteps the cache entirely and keeps each test's connection on its own loop.

clientAsyncClient (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: InMemoryItemRepository does 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).

from sqlalchemy import select
from src.shared.outbox.models import OutboxEvent

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.pymake_*() per aggregate root
  • [ ] tests/factories/modules/{module}/models.py*ModelFactory per SQLAlchemy model
  • [ ] tests/factories/modules/{module}/schemas.py*SchemaFactory per API request schema
  • [ ] tests/unit/modules/{module}/conftest.pyInMemory*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 — one Test{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 unit passes with no DB
  • [ ] pytest -m integration passes against docker-compose Postgres
  • [ ] pytest -m {module} runs all module tests cleanly
  • [ ] make lint-check and make type-check pass

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-up in CI)
  • The in-process dispatcher's own poll/claim machinery (covered by the outbox_dispatcher integration tests, GRI-116 — individual handler tests assert outbox rows, not the relay)
  • Trivial getters/setters with no logic
  • Framework-generated code