Backend Testing in Practice

Testing Domain Logic

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

In the previous lesson, we established our overall testing strategy:

Domain behavior
→ Domain test

UseCase orchestration
→ UseCase test

Persistence
→ PostgreSQL integration test

HTTP contract
→ API integration test

Concurrency
→ real concurrent integration test

Now we begin with the smallest and fastest layer:

Domain Tests

Our Domain contains business rules that can be evaluated entirely in memory.

Examples:

Product price must not be negative

Inventory quantity must not become negative

OrderItem quantity must be positive

OrderItem unitPriceCents must not be negative

Order must contain valid items

Order total must be derived correctly

UNPAID Order can become PAID

UNPAID Order can become CANCELLED

PAID Order cannot be cancelled

CANCELLED Order cannot be paid

None of those rules requires:

Spring Boot

PostgreSQL

JPA

HTTP

JWT

Payment Provider

The goal of this lesson is:

Test the Domain directly, using plain Java and JUnit, so business invariants and state transitions are protected by fast, deterministic tests that clearly document the behavior of the model.


What Are We Testing?

Our current Domain is approximately:

Product
ProductId

Inventory

Order
OrderId
OrderItem
OrderStatus

CustomerId

Each has different responsibilities.

For example:

Product
→ current Product validity and lifecycle
Inventory
→ valid local quantity state
OrderItem
→ purchase-time quantity and price
Order
→ ownership, items, total, lifecycle
ProductId / OrderId / CustomerId
→ valid identity representation

Tests should follow those responsibilities.


Domain Tests Should Test Domain Behavior

Suppose we want to prove:

PAID Order cannot be cancelled

The best test is:

@Test
void paidOrderCannotBeCancelled() {
    Order order =
            createOrder();

    order.markPaid();

    assertThrows(
            OrderNotCancellableException.class,
            order::cancel
    );

    assertEquals(
            OrderStatus.PAID,
            order.status()
    );
}

This test says exactly what the business rule is.

We do not need:

POST /api/v1/orders/{id}/cancel

to prove the Domain rule.

The HTTP test later proves the API mapping:

OrderNotCancellableException
→ 409 ORDER_NOT_CANCELLABLE

Different layer.

Different guarantee.


No Spring in Domain Tests

A Domain test should normally not use:

@SpringBootTest

or:

@Autowired

or:

@MockBean

A test such as:

Order order = createOrder();

order.cancel();

should run as ordinary Java.

That is one reason we deliberately kept:

Order

Product

Inventory

OrderItem

framework-light.


No Repository in Domain Tests

This would be a warning sign:

@Test
void orderTotalIsCorrect() {

    Product product =
            productRepository
                    .findById(...);

    ...
}

Order total does not depend on the Product Repository.

Once OrderItem exists, it already contains:

productId

quantity

unitPriceCents

So:

order.totalCents()

should be pure Domain behavior.


No HTTP in Domain Tests

Do not test:

Order.cancel()

only by sending:

POST /api/v1/orders/{id}/cancel

That would introduce:

HTTP

serialization

security

UseCase

Repository

database

to test one simple state transition.

Direct tests are faster and easier to diagnose.


No Mocks for Domain Objects

Avoid:

Order order =
        mock(Order.class);

to test Order lifecycle.

Use the real object.

Likewise:

Product product =
        mock(Product.class);

provides little value when Product itself is the behavior under test.

Domain objects are:

cheap

deterministic

in-memory

Use them directly.


Package Direction

Tests can mirror the production capability structure.

For example:

src/test/java/
└── io/tarkev/ordermanagement/
    ├── product/
    │   └── domain/
    │       └── ProductTest.java
    ├── inventory/
    │   └── domain/
    │       └── InventoryTest.java
    └── order/
        └── domain/
            ├── OrderItemTest.java
            └── OrderTest.java

This keeps test ownership clear.


Testing Product

Our Product owns:

id

name

priceCents

active

and meaningful operations such as:

product.rename(...);

product.changePrice(...);

product.deactivate();

The important invariant for money is:

priceCents >= 0

Zero remains valid.


Test — Product Starts Active

A newly created Product starts:

active = true

Conceptually:

class ProductTest {

    @Test
    void newProductStartsActive() {
        Product product =
                Product.create(
                        ProductId.newId(),
                        "Mechanical Keyboard",
                        8990L
                );

        assertTrue(
                product.active()
        );
    }
}

This protects the Product creation rule.


Test — Product Preserves Price in Cents

@Test
void productStoresPriceInCents() {
    Product product =
            Product.create(
                    ProductId.newId(),
                    "Mechanical Keyboard",
                    8990L
            );

    assertEquals(
            8990L,
            product.priceCents()
    );
}

Notice:

8990L

not:

89.90

The Domain uses integer minor units.


Test — Zero Price Is Valid

Our invariant is:

priceCents >= 0

not:

priceCents > 0

Therefore:

@Test
void zeroPriceIsValid() {
    Product product =
            Product.create(
                    ProductId.newId(),
                    "Free Sample",
                    0L
            );

    assertEquals(
            0L,
            product.priceCents()
    );
}

Do not invent a minimum price in the test suite.

Tests should protect requirements.

Not create requirements.


Test — Negative Product Price Is Rejected

@Test
void negativeProductPriceIsRejected() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    Product.create(
                            ProductId.newId(),
                            "Mechanical Keyboard",
                            -1L
                    )
    );
}

This protects:

priceCents >= 0

Test — Product Price Can Change

@Test
void changesProductPrice() {
    Product product =
            Product.create(
                    ProductId.newId(),
                    "Mechanical Keyboard",
                    8990L
            );

    product.changePrice(
            9990L
    );

    assertEquals(
            9990L,
            product.priceCents()
    );
}

Test — Changing to a Negative Price Is Rejected

The invariant must hold during mutation too.

@Test
void cannotChangeProductToNegativePrice() {
    Product product =
            Product.create(
                    ProductId.newId(),
                    "Mechanical Keyboard",
                    8990L
            );

    assertThrows(
            IllegalArgumentException.class,
            () ->
                    product.changePrice(
                            -1L
                    )
    );

    assertEquals(
            8990L,
            product.priceCents()
    );
}

The second assertion matters.

The failed operation must not leave:

priceCents = -1

behind.


Failed Mutation Should Preserve Previous State

This is a general Domain-testing principle.

For mutation:

valid state
    ↓
invalid operation

test both:

exception

and:

state remains valid

For Product:

assertThrows(...);

assertEquals(
        8990L,
        product.priceCents()
);

This gives stronger protection than checking only the exception.


Test — Product Can Be Deactivated

@Test
void deactivatesProduct() {
    Product product =
            createProduct();

    product.deactivate();

    assertFalse(
            product.active()
    );
}

Test — Deactivation Is Effect-Idempotent

Our Product deactivation behavior differs from Order cancellation.

Calling:

product.deactivate();
product.deactivate();

simply leaves:

active = false

So:

@Test
void repeatedDeactivationLeavesProductInactive() {
    Product product =
            createProduct();

    product.deactivate();
    product.deactivate();

    assertFalse(
            product.active()
    );
}

No exception is required for repeated Product deactivation.


Do Not Copy Order Semantics Into Product Tests

Order cancellation is intentionally different:

cancel twice
→ invalid

Product deactivation:

deactivate twice
→ remains inactive

The tests should preserve those semantic differences.

Do not generalize:

all state-changing operations
must behave the same

Testing Product Name Rules

Product also owns validity of its name.

Tests should protect the established rule that a Product name cannot be blank.

Conceptually:

@Test
void blankProductNameIsRejected() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    Product.create(
                            ProductId.newId(),
                            " ",
                            8990L
                    )
    );
}

Likewise mutation should preserve the invariant:

@Test
void cannotRenameProductToBlankName() {
    Product product =
            createProduct();

    assertThrows(
            IllegalArgumentException.class,
            () ->
                    product.rename(
                            " "
                    )
    );

    assertEquals(
            "Mechanical Keyboard",
            product.name()
    );
}

Do not add normalization assertions such as:

" Keyboard "
→ "Keyboard"

unless normalization is an agreed Product rule.


Testing Inventory

Inventory owns:

ProductId

availableQuantity

and protects:

availableQuantity >= 0

Its Domain operations may include:

inventory.decrease(...);

inventory.increase(...);

inventory.setAvailableQuantity(...);

These methods protect one loaded Inventory object's correctness.

They do not prove database concurrency.


Important Boundary

A Domain test can prove:

Inventory object
cannot intentionally become negative

It cannot prove:

two concurrent Order transactions
cannot oversell Inventory

That later requires:

InventoryRepository.consumeIfAvailable()

real PostgreSQL

concurrent transactions

Keep those guarantees separate.


Test — Inventory Starts With Valid Quantity

Conceptually:

@Test
void createsInventoryWithAvailableQuantity() {
    Inventory inventory =
            new Inventory(
                    ProductId.newId(),
                    10
            );

    assertEquals(
            10,
            inventory.availableQuantity()
    );
}

Test — Zero Inventory Is Valid

Our invariant is:

availableQuantity >= 0

So:

@Test
void zeroInventoryIsValid() {
    Inventory inventory =
            new Inventory(
                    ProductId.newId(),
                    0
            );

    assertEquals(
            0,
            inventory.availableQuantity()
    );
}

Zero means:

no units currently available

not:

invalid Inventory

Test — Negative Inventory Is Rejected

@Test
void negativeInventoryIsRejected() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    new Inventory(
                            ProductId.newId(),
                            -1
                    )
    );
}

Test — Decreasing Inventory

Given:

availableQuantity = 10

and:

decrease = 3

expected:

7

Test:

@Test
void decreasesAvailableQuantity() {
    Inventory inventory =
            inventoryWithQuantity(
                    10
            );

    inventory.decrease(
            3
    );

    assertEquals(
            7,
            inventory.availableQuantity()
    );
}

Test — Cannot Decrease Below Zero

@Test
void cannotDecreaseBelowZero() {
    Inventory inventory =
            inventoryWithQuantity(
                    2
            );

    assertThrows(
            IllegalArgumentException.class,
            () ->
                    inventory.decrease(
                            3
                    )
    );

    assertEquals(
            2,
            inventory.availableQuantity()
    );
}

Again:

failure

must preserve:

previous valid state

Test — Restore / Increase Inventory

@Test
void increasesAvailableQuantity() {
    Inventory inventory =
            inventoryWithQuantity(
                    5
            );

    inventory.increase(
            2
    );

    assertEquals(
            7,
            inventory.availableQuantity()
    );
}

The Domain arithmetic is simple.

Database concurrency is not being tested here.


Test — Administrator Quantity Setting

If the Domain supports:

inventory.setAvailableQuantity(...);

then test:

@Test
void setsAvailableQuantity() {
    Inventory inventory =
            inventoryWithQuantity(
                    5
            );

    inventory.setAvailableQuantity(
            20
    );

    assertEquals(
            20,
            inventory.availableQuantity()
    );
}

and:

@Test
void cannotSetNegativeAvailableQuantity() {
    Inventory inventory =
            inventoryWithQuantity(
                    5
            );

    assertThrows(
            IllegalArgumentException.class,
            () ->
                    inventory.setAvailableQuantity(
                            -1
                    )
    );

    assertEquals(
            5,
            inventory.availableQuantity()
    );
}

Do Not Use Inventory Domain Tests to Prove Oversell Protection

This would be misleading:

Inventory.decrease() test passes
    ↓
therefore concurrent stock consumption is safe

No.

Inventory.decrease() protects:

one object

Our production Order workflow consumes stock using:

InventoryRepository.consumeIfAvailable(...)

with an atomic database update.

That concurrency guarantee gets its own integration tests later.


Testing OrderItem

OrderItem represents purchase-time line information:

ProductId

quantity

unitPriceCents

It protects:

quantity > 0

unitPriceCents >= 0

and calculates:

totalCents

using exact arithmetic.


Test — Valid OrderItem

@Test
void createsOrderItem() {
    ProductId productId =
            ProductId.newId();

    OrderItem item =
            new OrderItem(
                    productId,
                    2,
                    8990L
            );

    assertEquals(
            productId,
            item.productId()
    );

    assertEquals(
            2,
            item.quantity()
    );

    assertEquals(
            8990L,
            item.unitPriceCents()
    );
}

Test — Quantity Must Be Positive

Valid:

1
2
10

Invalid:

0

negative

Test zero:

@Test
void zeroQuantityIsRejected() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    new OrderItem(
                            ProductId.newId(),
                            0,
                            8990L
                    )
    );
}

And negative:

@Test
void negativeQuantityIsRejected() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    new OrderItem(
                            ProductId.newId(),
                            -1,
                            8990L
                    )
    );
}

Test — Zero Unit Price Is Valid

Again:

unitPriceCents >= 0

So:

@Test
void zeroUnitPriceIsValid() {
    OrderItem item =
            new OrderItem(
                    ProductId.newId(),
                    5,
                    0L
            );

    assertEquals(
            0L,
            item.totalCents()
    );
}

Do not invent:

minimum price = 1 cent

Test — Negative Unit Price Is Rejected

@Test
void negativeUnitPriceIsRejected() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    new OrderItem(
                            ProductId.newId(),
                            1,
                            -1L
                    )
    );
}

Test — OrderItem Total

Given:

unitPriceCents = 8990

quantity = 2

expected:

17980

Test:

@Test
void calculatesItemTotal() {
    OrderItem item =
            new OrderItem(
                    ProductId.newId(),
                    2,
                    8990L
            );

    assertEquals(
            17980L,
            item.totalCents()
    );
}

No rounding.

No decimal arithmetic.

Exact cents.


Test — Multiplication Overflow

OrderItem.totalCents() uses:

Math.multiplyExact(
        unitPriceCents,
        quantity
);

Therefore overflow should fail visibly.

Conceptually:

@Test
void itemTotalFailsOnOverflow() {
    OrderItem item =
            new OrderItem(
                    ProductId.newId(),
                    2,
                    Long.MAX_VALUE
            );

    assertThrows(
            ArithmeticException.class,
            item::totalCents
    );
}

This tests a technical correctness property:

wrong money
must never be silently produced

Overflow Is Not a Business Rule

Do not translate this test into an invented requirement such as:

maximum Product price = 1,000,000 cents

or:

maximum Order quantity = 100

unless product requirements define those limits.

Checked arithmetic solves:

numeric correctness

without inventing business limits.


Testing Order

Order is the richest Domain object in the current project.

It protects:

OrderId

CustomerId

non-empty items

unique Product lines

historical item data

derived total

lifecycle

Its lifecycle is:

UNPAID
   ├──→ PAID
   └──→ CANCELLED

Both terminal states reject further transitions.


Test — New Order Starts UNPAID

@Test
void newOrderStartsUnpaid() {
    Order order =
            createOrder();

    assertEquals(
            OrderStatus.UNPAID,
            order.status()
    );
}

This protects:

Order.create(...)
→ UNPAID

Test — Ownership Is Preserved

@Test
void orderKeepsCustomerIdentity() {
    CustomerId customerId =
            new CustomerId(
                    "customer-123"
            );

    Order order =
            createOrderFor(
                    customerId
            );

    assertEquals(
            customerId,
            order.customerId()
    );
}

Or, if the Domain API exposes:

belongsTo(...)

test the behavior:

@Test
void orderBelongsToItsCustomer() {
    CustomerId customerId =
            new CustomerId(
                    "customer-123"
            );

    Order order =
            createOrderFor(
                    customerId
            );

    assertTrue(
            order.belongsTo(
                    customerId
            )
    );
}

Domain Ownership Is Not Authorization

Do not use this test to claim:

security works

It only proves:

Order retains its owner identity

Whether the authenticated caller is allowed to use that Order is:

UseCase/security behavior

tested later.


Test — Order Requires Items

Our Domain does not permit an Order with no OrderItems.

Conceptually:

@Test
void orderRequiresAtLeastOneItem() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    Order.create(
                            OrderId.newId(),
                            new CustomerId(
                                    "customer-123"
                            ),
                            List.of(),
                            Instant.parse(
                                    "2026-10-04T12:00:00Z"
                            )
                    )
    );
}

Use the actual constructor/factory signature implemented in the project.

The invariant being protected is:

Order items must not be empty

Test — Duplicate Products Are Rejected

An Order must not contain two lines for the same Product.

Given:

Product A × 2
Product A × 3

the Domain rejects the structure.

Conceptually:

@Test
void duplicateProductLinesAreRejected() {
    ProductId productId =
            ProductId.newId();

    OrderItem first =
            new OrderItem(
                    productId,
                    2,
                    8990L
            );

    OrderItem second =
            new OrderItem(
                    productId,
                    3,
                    8990L
            );

    assertThrows(
            IllegalArgumentException.class,
            () ->
                    createOrderWithItems(
                            List.of(
                                    first,
                                    second
                            )
                    )
    );
}

We also reject duplicates earlier in CreateOrderUseCase.

Why both?

UseCase
→ fail before side effects

Domain
→ prevent invalid Order existence

Different protection.


Test — Order Total

Given:

Item A
unitPriceCents = 8990
quantity = 2

Subtotal:

17980

And:

Item B
unitPriceCents = 6950
quantity = 1

Subtotal:

6950

Expected Order total:

24930

Test:

@Test
void calculatesOrderTotal() {
    Order order =
            createOrderWithItems(
                    List.of(
                            new OrderItem(
                                    ProductId.newId(),
                                    2,
                                    8990L
                            ),
                            new OrderItem(
                                    ProductId.newId(),
                                    1,
                                    6950L
                            )
                    )
            );

    assertEquals(
            24930L,
            order.totalCents()
    );
}

Order Total Is Derived

This test should not set:

order.setTotalCents(
        24930L
);

There is no independent mutable Order total.

The source of truth is:

OrderItems

So the test creates items and verifies the result.

That protects our design against duplicated monetary state.


Test — Zero-Priced Items

@Test
void zeroPricedItemsProduceZeroSubtotal() {
    Order order =
            createOrderWithItems(
                    List.of(
                            new OrderItem(
                                    ProductId.newId(),
                                    5,
                                    0L
                            )
                    )
            );

    assertEquals(
            0L,
            order.totalCents()
    );
}

Again:

zero price

is valid under current requirements.


Test — Order Total Addition Overflow

Order uses checked accumulation:

Math.addExact(...)

So we should protect that behavior.

Conceptually:

@Test
void orderTotalFailsOnOverflow() {
    Order order =
            createOrderWithItems(
                    List.of(
                            new OrderItem(
                                    ProductId.newId(),
                                    1,
                                    Long.MAX_VALUE
                            ),
                            new OrderItem(
                                    ProductId.newId(),
                                    1,
                                    1L
                            )
                    )
            );

    assertThrows(
            ArithmeticException.class,
            order::totalCents
    );
}

This prevents silent monetary corruption.


Testing the Order Lifecycle

The lifecycle matrix is:

Current stateOperationResult
UNPAIDPayPAID
UNPAIDCancelCANCELLED
PAIDPayORDER_NOT_PAYABLE
PAIDCancelORDER_NOT_CANCELLABLE
CANCELLEDPayORDER_NOT_PAYABLE
CANCELLEDCancelORDER_NOT_CANCELLABLE

Every row deserves protection.


Test — UNPAID → PAID

@Test
void unpaidOrderCanBeMarkedPaid() {
    Order order =
            createOrder();

    order.markPaid();

    assertEquals(
            OrderStatus.PAID,
            order.status()
    );
}

Test — UNPAID → CANCELLED

@Test
void unpaidOrderCanBeCancelled() {
    Order order =
            createOrder();

    order.cancel();

    assertEquals(
            OrderStatus.CANCELLED,
            order.status()
    );
}

Test — PAID Cannot Be Paid Again

@Test
void paidOrderCannotBeMarkedPaidAgain() {
    Order order =
            createOrder();

    order.markPaid();

    assertThrows(
            OrderNotPayableException.class,
            order::markPaid
    );

    assertEquals(
            OrderStatus.PAID,
            order.status()
    );
}

This protects local lifecycle correctness.

But remember:

This does not prove the external Payment Provider cannot charge twice.

External duplicate-charge protection belongs to:

Payment idempotency tests

later.


Test — PAID Cannot Be Cancelled

@Test
void paidOrderCannotBeCancelled() {
    Order order =
            createOrder();

    order.markPaid();

    assertThrows(
            OrderNotCancellableException.class,
            order::cancel
    );

    assertEquals(
            OrderStatus.PAID,
            order.status()
    );
}

No refund exists in v1.

So:

PAID → CANCELLED

is invalid.


Test — CANCELLED Cannot Be Paid

@Test
void cancelledOrderCannotBeMarkedPaid() {
    Order order =
            createOrder();

    order.cancel();

    assertThrows(
            OrderNotPayableException.class,
            order::markPaid
    );

    assertEquals(
            OrderStatus.CANCELLED,
            order.status()
    );
}

Test — CANCELLED Cannot Be Cancelled Again

@Test
void cancelledOrderCannotBeCancelledAgain() {
    Order order =
            createOrder();

    order.cancel();

    assertThrows(
            OrderNotCancellableException.class,
            order::cancel
    );

    assertEquals(
            OrderStatus.CANCELLED,
            order.status()
    );
}

This rule is especially important because application-level cancellation also restores Inventory.

Repeated cancellation is not merely harmless repetition.


Why Test State After Rejected Transition?

Consider:

assertThrows(
        OrderNotCancellableException.class,
        order::cancel
);

That proves the call failed.

But also assert:

assertEquals(
        OrderStatus.PAID,
        order.status()
);

because we need to prove:

failed operation
did not partially mutate the Order

This is a strong pattern for mutable Domain tests.


Testing Reconstitution

We deliberately distinguish:

Order.create(...)

from:

Order.reconstitute(...)

Creation always establishes:

UNPAID

Reconstitution restores persisted state.

So:

@Test
void reconstitutesPaidOrder() {
    Order order =
            Order.reconstitute(
                    OrderId.newId(),
                    new CustomerId(
                            "customer-123"
                    ),
                    validItems(),
                    OrderStatus.PAID,
                    Instant.parse(
                            "2026-10-04T12:00:00Z"
                    )
            );

    assertEquals(
            OrderStatus.PAID,
            order.status()
    );
}

Reconstitution must not silently reset:

PAID

to:

UNPAID

Reconstitution Is Not a Backdoor Mutation API

Do not write tests that encourage:

current Order
→ reconstitute with another status
→ treat as mutation

reconstitute() exists for:

persistence reconstruction

Lifecycle changes still use:

markPaid();

cancel();

Testing CustomerId

Our external Customer identity is represented by a simple:

stable non-blank String

It is not assumed to be a UUID.

Test valid identity:

@Test
void acceptsStableExternalCustomerId() {
    CustomerId customerId =
            new CustomerId(
                    "auth0|customer-123"
            );

    assertEquals(
            "auth0|customer-123",
            customerId.value()
    );
}

Test — CustomerId Cannot Be Blank

@Test
void customerIdCannotBeBlank() {
    assertThrows(
            IllegalArgumentException.class,
            () ->
                    new CustomerId(
                            " "
                    )
    );
}

And null handling can be tested according to its constructor contract.


Do Not Test UUID Assumptions for CustomerId

Bad test:

CustomerId must be UUID

That is not our requirement.

The external identity provider owns its subject format.

Our application requires only:

stable

non-blank

identity.


Testing ProductId and OrderId

Product and Order identities are application-generated UUIDs.

The useful Domain tests should focus on their value semantics.

For example:

@Test
void productIdsWithSameUuidAreEqual() {
    UUID value =
            UUID.randomUUID();

    assertEquals(
            new ProductId(value),
            new ProductId(value)
    );
}

Likewise for OrderId.


Avoid Testing Java Itself

Do not write excessive tests such as:

UUID.toString()
returns a String

or:

record accessor returns value

unless we add meaningful custom behavior.

A record generated by Java does not need dozens of tests merely to increase coverage.


Test Business Rules, Not Syntax

Good:

negative Product price rejected

Good:

cancelled Order cannot be paid

Low value:

product.priceCents()
returns field assigned in constructor

unless the test is part of a meaningful construction scenario.

Focus effort on invariants and behavior.


Test Helpers

Domain tests often need valid objects as setup.

We can create small test helpers.

Example:

private Product createProduct() {
    return Product.create(
            ProductId.newId(),
            "Mechanical Keyboard",
            8990L
    );
}

For Order:

private Order createOrder() {
    return createOrderWithItems(
            List.of(
                    new OrderItem(
                            ProductId.newId(),
                            1,
                            8990L
                    )
            )
    );
}

This keeps individual tests focused.


Keep Test Helpers Boring

Avoid a giant generic builder framework like:

DomainObjectFactoryFactory

UniversalEntityBuilder<T>

TestFixtureEngine

for a small Domain.

Test infrastructure should make tests easier to understand.

Not harder.


Builders Become Useful When Setup Grows

As scenarios become richer, something like:

Order order =
        anOrder()
                .forCustomer(
                        customerId
                )
                .withItem(
                        productId,
                        2,
                        8990L
                )
                .build();

can be useful.

But a reader must still easily see:

what state matters
for this test

Do not hide every default behind layers of magic.


Fixed Values Improve Readability

For Domain tests, explicit values are often clearer than random values.

Example:

priceCents = 8990
quantity = 2
expected = 17980

is easy to reason about.

Randomizing every test with:

ThreadLocalRandom

usually adds noise.

Randomized/property testing is useful for specific problems, but it should not replace readable examples.


Time in Domain Tests

Order stores:

createdAt

generated by the application using an injected Clock.

When testing behavior that depends on the timestamp, use a fixed value:

Instant createdAt =
        Instant.parse(
                "2026-10-04T12:00:00Z"
        );

Do not use:

Instant.now()

and then assert fragile timing windows.

Determinism matters.


What Should Not Be Tested Here?

Domain tests should not prove:

Product row persists

That is Repository integration.

They should not prove:

CHECK constraint rejects negative price

That is database integration.

They should not prove:

HTTP negative price returns 400

That is API testing.

They should not prove:

ADMIN can change Product price

That is security/API integration.

They should not prove:

two concurrent Inventory consumers
cannot oversell

That is concurrency integration.


Domain Rule vs Database Rule

Some invariants exist in both places.

Example:

priceCents >= 0

is protected by:

Product

and:

PostgreSQL CHECK

That does not make one test redundant.

Domain test proves:

normal application objects
cannot enter invalid state

Database test proves:

invalid persisted state
is rejected at the storage boundary

Defense in depth.


Domain Rule vs HTTP Validation

Quantity also appears in multiple layers.

HTTP:

quantity <= 0
→ 400 validation error

Domain:

OrderItem quantity > 0

Why both?

HTTP validation gives clients:

good request feedback

Domain validation guarantees:

invalid OrderItem
cannot exist

even if the object is constructed from:

another application path

test fixture

future background process

Test Invariants at Their Owner

Suppose the test is:

Order total is correct

Test:

Order

Do not place the assertion only in:

CreateOrderUseCaseTest

because the calculation belongs to the Domain.

Likewise:

Product price cannot be negative

belongs primarily in:

ProductTest

not only:

UpdateProductUseCaseTest

Tests should reveal responsibility.


Tests Become Domain Documentation

Consider these test names:

newOrderStartsUnpaid

unpaidOrderCanBeMarkedPaid

paidOrderCannotBeCancelled

cancelledOrderCannotBeMarkedPaid

zeroProductPriceIsValid

duplicateProductLinesAreRejected

itemTotalFailsOnOverflow

A new engineer can read the test class and understand much of the business model.

That is valuable.


Prefer Behavior-Oriented Test Names

Good:

void cancelledOrderCannotBeMarkedPaid()

Weak:

void testMarkPaid2()

Good:

void zeroProductPriceIsValid()

Weak:

void testPrice()

Tests should communicate business meaning.


One Behavior Per Test

Prefer:

one clear reason for failure

For example:

@Test
void negativeProductPriceIsRejected()

rather than one enormous test:

@Test
void productValidationWorks() {
    // null
    // blank
    // negative
    // rename
    // deactivate
    // change price
    // ...
}

Smaller tests are easier to diagnose.


Parameterized Tests

JUnit parameterized tests can be useful when the behavior is truly the same across several inputs.

For example, if:

0
-1
-100

all represent invalid quantity except that zero/negative have the same rule:

quantity must be positive

a parameterized test could be reasonable.

But do not hide conceptually different rules merely to reduce line count.

Readable tests are more important than clever test compression.


Should We Test Exception Messages?

Usually the important contract at the Domain layer is:

exception type
+
state preservation

Exact exception text is often less important unless we deliberately treat it as stable application behavior.

For example:

assertThrows(
        OrderNotPayableException.class,
        order::markPaid
);

is usually stronger than coupling the test to:

"Only unpaid orders can be paid"

unless that message itself has a requirement.

Public error wording belongs to API tests.


Do Not Test API Error Codes in Domain Tests

Order throws:

OrderNotPayableException

The Domain should not know:

409

ORDER_NOT_PAYABLE

https://api.tarkev.com/problems/order-not-payable

Those belong to the HTTP mapping layer.

So OrderTest should not assert:

response.status == 409

There is no response at the Domain layer.


Do Not Test Payment Provider Behavior in OrderTest

This would be wrong:

@Test
void markingPaidCallsProvider() {
    ...
}

Order.markPaid() does not call a provider.

The external workflow is:

PayOrderUseCase
→ PaymentService
→ provider
→ confirmed success
→ order.markPaid()

Domain tests protect only:

the lifecycle transition

Do Not Test Inventory Restoration in Order.cancel()

Likewise:

order.cancel();

does not restore Inventory itself.

CancelOrderUseCase coordinates:

Order.cancel()

+

InventoryRepository.restore(...)

So Domain test:

order.cancel()
→ status CANCELLED

UseCase/integration tests:

successful cancellation
→ Inventory restored

Again:

test responsibility
follows code responsibility

Domain Tests and Concurrency

Domain state-machine tests do not prove concurrent persistence correctness.

Example:

order.cancel();

works correctly for one object.

That does not prove two application instances cannot both load:

UNPAID

and race.

Likewise:

inventory.decrease(4);

does not prove two PostgreSQL transactions cannot oversell.

Those are separate tests later in this module.


Domain Tests Should Be Extremely Fast

A healthy Domain test suite should run quickly enough that developers run it constantly.

Think:

edit

run tests

edit

run tests

without waiting for:

containers

Spring context

network

That fast feedback is one reason to preserve clear Domain boundaries.


Domain Test Suite Example

A useful initial suite may look like:

ProductTest
├── newProductStartsActive
├── zeroPriceIsValid
├── negativePriceIsRejected
├── changesPrice
├── negativeChangedPriceIsRejected
├── blankNameIsRejected
├── renamesProduct
├── blankRenameIsRejected
├── deactivatesProduct
└── repeatedDeactivationLeavesInactive

InventoryTest
├── createsWithNonNegativeQuantity
├── zeroQuantityIsValid
├── negativeQuantityIsRejected
├── decreasesQuantity
├── cannotDecreaseBelowZero
├── increasesQuantity
├── setsAvailableQuantity
└── cannotSetNegativeQuantity

OrderItemTest
├── createsValidItem
├── zeroQuantityIsRejected
├── negativeQuantityIsRejected
├── zeroUnitPriceIsValid
├── negativeUnitPriceIsRejected
├── calculatesTotalCents
└── totalFailsOnOverflow

OrderTest
├── requiresItems
├── rejectsDuplicateProducts
├── startsUnpaid
├── calculatesTotalCents
├── totalFailsOnOverflow
├── unpaidCanBecomePaid
├── unpaidCanBecomeCancelled
├── paidCannotBecomePaidAgain
├── paidCannotBeCancelled
├── cancelledCannotBePaid
├── cancelledCannotBeCancelledAgain
└── reconstitutesExistingStatus

CustomerIdTest
├── acceptsStableNonBlankValue
└── rejectsBlankValue

This is not a mandatory exact file count.

It illustrates the behaviors worth protecting.


Do Not Test Framework-Free Domain With Framework Test Infrastructure

A test class such as:

@SpringBootTest
class OrderTest {
}

would work.

But it would be unnecessary.

It would make:

simple business tests

depend on:

Spring configuration

for no reason.

This increases execution cost and coupling.


A Test Should Fail for the Right Reason

Suppose:

paidOrderCannotBeCancelled

fails.

We want the failure to mean:

Order lifecycle behavior changed

not:

Docker unavailable

or:

database migration broken

or:

security configuration failed

Focused Domain tests give us that isolation.


Domain Test Anti-Patterns

Avoid:

@SpringBootTest
for ProductTest

Avoid:

mock(Order.class)

Avoid:

mock(Product.class)

Avoid:

Repository
inside total calculation test

Avoid:

HTTP
to test Order state machine

Avoid testing database constraints here.

Avoid testing authorization here.

Avoid testing provider calls here.

Avoid random sleeps.

Avoid random test data when fixed examples communicate better.

Avoid asserting implementation details such as private fields through reflection.

Avoid weakening Domain visibility just so a test can mutate invalid state.


Test Through the Public Domain API

For lifecycle:

order.markPaid();

not:

setPrivateField(
        order,
        "status",
        PAID
);

For Product:

product.changePrice(...);

not reflection mutation.

For Inventory:

inventory.decrease(...);

not private-field modification.

Tests should use Domain behavior the same way production application code does.


What If We Need an Invalid Existing State?

Sometimes persistence tests need to determine what happens with invalid database values.

That is not a Domain-test problem.

If the Domain intentionally prevents an object from existing in that invalid state:

good

Test database safeguards separately.

Do not add:

Order.unsafeCreateForTest(...)

to production code.


Domain Tests and Refactoring

Good Domain tests should survive internal refactoring.

For example, we could change:

if statement

to:

switch

inside Order.markPaid().

Tests should remain unchanged because public behavior did not change.

If every refactor breaks dozens of tests despite unchanged behavior, tests may be too coupled to implementation.


Tests Protect Canonical Decisions

Our Domain tests should also make architectural decisions difficult to regress accidentally.

Examples:

zero price remains valid
money remains long cents
Order total remains derived
new Order starts UNPAID
PAID and CANCELLED are terminal
CustomerId is not assumed UUID

These tests become executable reminders of decisions made earlier in the course.


Exercise — Complete the Order Transition Matrix

Write tests for all six transition combinations:

UNPAID + markPaid
UNPAID + cancel

PAID + markPaid
PAID + cancel

CANCELLED + markPaid
CANCELLED + cancel

For rejected transitions verify:

correct exception

and:

state unchanged

This is a compact but powerful state-machine test suite.


Exercise — Monetary Boundary Tests

Add tests for:

Product priceCents = 0
Product priceCents = -1

OrderItem unitPriceCents = 0
OrderItem unitPriceCents = -1

OrderItem quantity = 1
OrderItem quantity = 0

normal multiplication

multiplication overflow

normal Order total

addition overflow

The objective is:

exact or fail

Never:

silently wrong money

Exercise — Inventory Invariants

Given:

Inventory = 10

test:

decrease 3
→ 7

increase 2
→ 12

set 0
→ 0

decrease more than available
→ reject
→ previous state preserved

set -1
→ reject
→ previous state preserved

Then ask:

Which of these tests proves concurrent oversell protection?

Answer:

none

That requires a PostgreSQL concurrency test.


Exercise — Domain vs Application

For each behavior, decide where the test belongs:

Order cannot become PAID after cancellation

Answer:

Domain
Customer cannot pay another Customer's Order

Answer:

UseCase / security integration
Inventory update rolls back when Order save fails

Answer:

transaction integration
POST /pay returns 409 for CANCELLED

Answer:

API integration

This distinction is central to good backend testing.


Engineering Principle

The core principle is:

Domain tests should exercise business behavior directly, without Spring or infrastructure. If a rule can be proven by constructing an object and calling its methods, that is usually the best place to test it.

Another:

A rejected Domain operation should not only throw the expected error; the object should remain in its previous valid state.

And:

Domain tests prove in-memory business correctness. They do not prove persistence, authorization, external-service behavior, transactions, or concurrency. Those guarantees belong to other test layers.


Summary

In this lesson, we established that:

  • Domain tests are plain Java/JUnit tests.
  • Domain tests should not boot Spring.
  • Domain tests should not require PostgreSQL.
  • Domain tests should not require HTTP or security.
  • Domain objects generally should not be mocked.
  • Domain tests protect business invariants and state transitions directly.
  • Product tests cover creation, price rules, name rules, price changes, and deactivation.
  • Product price uses long priceCents.
  • Zero Product price remains valid.
  • Negative Product price is rejected.
  • Failed Product mutations should preserve previous state.
  • Product deactivation is effect-idempotent.
  • Inventory tests protect non-negative local quantity state.
  • Zero Inventory is valid.
  • Inventory cannot locally decrease below zero.
  • Inventory Domain tests do not prove database-level oversell protection.
  • OrderItem quantity must be positive.
  • OrderItem unitPriceCents must be non-negative.
  • Zero-price OrderItems are valid.
  • OrderItem.totalCents() uses exact multiplication.
  • Arithmetic overflow should throw rather than silently corrupt money.
  • Order requires valid non-empty items.
  • Duplicate Product lines are invalid.
  • Order total is derived from OrderItems.
  • Order total uses historical unitPriceCents.
  • Order total does not query Product or Repository.
  • Checked addition protects Order total from silent overflow.
  • New Orders start UNPAID.
  • UNPAID → PAID is valid.
  • UNPAID → CANCELLED is valid.
  • PAID → PAID is invalid.
  • PAID → CANCELLED is invalid.
  • CANCELLED → PAID is invalid.
  • CANCELLED → CANCELLED is invalid.
  • Rejected transitions should leave Order state unchanged.
  • Reconstitution restores persisted state rather than resetting to UNPAID.
  • CustomerId is a stable non-blank external identifier.
  • CustomerId is not assumed to be UUID.
  • UUID value objects should be tested only for meaningful custom semantics rather than testing Java itself.
  • Tests should focus on behavior rather than private implementation.
  • Test helpers should keep scenarios readable.
  • Fixed deterministic values are generally preferable to unnecessary randomness.
  • Time-sensitive tests should use fixed Instants/Clock behavior.
  • Domain tests should not prove persistence constraints.
  • Domain tests should not prove HTTP error mapping.
  • Domain tests should not prove authorization.
  • Domain tests should not prove Payment Provider interaction.
  • Domain tests should not prove concurrency.
  • The same invariant may have Domain and database tests when each protects a different boundary.
  • Tests should make canonical design decisions executable and difficult to regress accidentally.

Next lesson:

Testing UseCases with Test Doubles

We will move one level outward and test application orchestration such as:

CreateProductUseCase

UpdateProductUseCase

CreateOrderUseCase

CancelOrderUseCase

PayOrderUseCase

using:

fakes

stubs

mocks

appropriately.

The focus will be on proving:

validation order

boundary interactions

ownership behavior

side effects that must not happen after failure

server-authoritative pricing

PaymentService interaction

failure propagation

without booting Spring or PostgreSQL when infrastructure behavior is not what the test is trying to prove.