Skip to content

Domain Model Tests

Domain model tests are unit tests that validate your aggregates, entities, value objects, invariants, and domain services. They are the foundation of your test suite — fast, isolated, and focused on business rules.

Key Facts

  • Domain model tests require no infrastructure — they run entirely in-memory.
  • They test your business logic: state transitions, invariant enforcement, event raising, and domain service orchestration.
  • They are the fastest tests in your suite and should form the bulk of your coverage.
  • Every business rule encoded in your domain model should have a corresponding test.

Don't Test What Protean Guarantees

Protean already guarantees that fields work correctly — required=True raises validation errors, max_length is enforced, default values are applied, value objects are immutable and compared by value, and events are dispatched in order. You do not need to write tests for these behaviors. Focus your tests on logic you wrote: custom methods, state transitions, invariants, and domain rules.

Test Setup

Domain model tests import your application's domain and initialize it with in-memory adapters (the default). Override processing to "sync" so that events and commands are handled immediately within tests:

# tests/conftest.py
import pytest

from myapp import domain


@pytest.fixture(autouse=True)
def setup_domain():
    domain.config["event_processing"] = "sync"
    domain.config["command_processing"] = "sync"
    domain.init()

    with domain.domain_context():
        yield

Since your domain elements are already decorated and registered in your application code, you do not need to register them again in tests — domain.init() discovers and wires everything automatically.

Note

You do not need Docker, databases, or message brokers for domain model tests. Protean's in-memory adapters are used by default.

Testing Aggregates

Aggregates are the core of your domain model. Test that your custom methods correctly transition state and raise events.

State Transitions

Test that aggregate methods correctly mutate state. These are methods you wrote — your business logic:

from myapp.models import Book


def test_publish_changes_status():
    book = Book(
        title="Dune",
        author="Frank Herbert",
        body="A lengthy description...",
    )
    book.publish()

    assert book.status == "PUBLISHED"

Event Raising

Verify that your aggregate methods raise the expected domain events:

from myapp.models import Book
from myapp.events import BookPublished


def test_publish_raises_event():
    book = Book(
        title="Dune",
        author="Frank Herbert",
        body="A lengthy description...",
    )
    book.publish()

    assert len(book._events) == 1
    assert isinstance(book._events[0], BookPublished)
    assert book._events[0].book_id == book.id

Testing Entities

Entities live within aggregates and have their own identity. Test the business logic in entity methods, especially when they affect the parent aggregate's state:

from myapp.models import Order, OrderItem


def test_cancel_item_recalculates_order_total():
    order = Order(
        customer_name="Alice",
        total_amount=30.0,
        items=[
            OrderItem(product_id="prod-1", quantity=2, price=10.0, subtotal=20.0),
            OrderItem(product_id="prod-2", quantity=1, price=10.0, subtotal=10.0),
        ],
    )
    order.cancel_item("prod-2")

    assert len(order.items) == 1
    assert order.total_amount == 20.0

Testing Value Objects

Protean guarantees that value objects are immutable and compared by value — you do not need to test these properties. Instead, test any custom logic you define on your value objects:

from myapp.models import Money


def test_money_addition():
    m1 = Money(amount=12.99, currency="USD")
    m2 = Money(amount=7.01, currency="USD")

    total = m1.add(m2)

    assert total.amount == 20.00
    assert total.currency == "USD"


def test_money_rejects_different_currencies():
    usd = Money(amount=10.0, currency="USD")
    eur = Money(amount=10.0, currency="EUR")

    with pytest.raises(ValidationError) as exc:
        usd.add(eur)
    assert "Cannot add different currencies" in str(exc.value.messages)

Testing Invariants

Invariants are your business rules — they represent domain constraints that you define. Protean guarantees that invariants are enforced (triggered on initialization and mutation), but you should test that your invariant logic is correct and catches the violations you intend.

Testing That Invalid State Is Rejected

from myapp.models import Order, OrderItem


def test_order_total_must_match_items():
    with pytest.raises(ValidationError) as exc:
        Order(
            customer_id="1",
            total_amount=100.0,  # Does not match items
            items=[
                OrderItem(product_id="1", quantity=2, price=10.0, subtotal=20.0),
            ],
        )
    assert "Total should be sum of item prices" in str(exc.value.messages)

Testing That Valid State Is Accepted

Don't just test the negative case — verify that correctly constructed aggregates pass your invariant:

def test_order_with_matching_total_is_valid():
    order = Order(
        customer_id="1",
        total_amount=20.0,
        items=[
            OrderItem(product_id="1", quantity=2, price=10.0, subtotal=20.0),
        ],
    )
    assert order is not None
    assert order.total_amount == 20.0

Pre-Invariants

Pre-invariants validate state before a mutation occurs. They are useful for guard conditions — test that your guard logic correctly rejects invalid operations:

def test_cannot_modify_shipped_order():
    order = Order(customer_name="Alice", status="SHIPPED")

    with pytest.raises(ValidationError) as exc:
        order.add_item("Another Book", 1, Money(amount=9.99))
    assert "shipped" in str(exc.value.messages)

Testing Domain Services

Domain services encapsulate business rules that span multiple aggregates. Test them by providing the required aggregates and verifying the outcome.

from myapp.models import Order, OrderItem, Inventory
from myapp.services import OrderFulfillmentService


def test_order_fulfillment():
    order = Order(
        customer_name="Alice",
        items=[OrderItem(book_title="Dune", quantity=2)],
    )
    inventory = Inventory(book_id="1", title="Dune", quantity=10)

    service = OrderFulfillmentService(order, [inventory])
    service.fulfill()

    assert order.status == "CONFIRMED"
    assert inventory.quantity == 8  # 10 - 2

Domain Service Invariants

Domain services can also have pre-invariants. Test that they reject invalid input:

def test_fulfillment_rejects_out_of_stock():
    order = Order(
        customer_name="Alice",
        items=[OrderItem(book_title="Dune", quantity=20)],
    )
    inventory = Inventory(book_id="1", title="Dune", quantity=5)

    with pytest.raises(ValidationError) as exc:
        service = OrderFulfillmentService(order, [inventory])
        service.fulfill()
    assert "not in stock" in str(exc.value.messages)

Organizing Domain Model Tests

A recommended directory structure:

tests/
├── conftest.py              # Domain fixture
├── test_book.py             # Aggregate tests
├── test_order.py            # Aggregate + entity tests
├── test_money.py            # Value object tests
├── test_fulfillment.py      # Domain service tests
└── ...

Group tests by the aggregate or concept they exercise. Each test file imports the elements it needs from your application code.