Backend Testing in Practice
Testing Domain Logic
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 state | Operation | Result |
|---|---|---|
UNPAID | Pay | PAID |
UNPAID | Cancel | CANCELLED |
PAID | Pay | ORDER_NOT_PAYABLE |
PAID | Cancel | ORDER_NOT_CANCELLABLE |
CANCELLED | Pay | ORDER_NOT_PAYABLE |
CANCELLED | Cancel | ORDER_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
unitPriceCentsmust 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 → PAIDis valid.UNPAID → CANCELLEDis valid.PAID → PAIDis invalid.PAID → CANCELLEDis invalid.CANCELLED → PAIDis invalid.CANCELLED → CANCELLEDis invalid.- Rejected transitions should leave Order state unchanged.
- Reconstitution restores persisted state rather than resetting to
UNPAID. CustomerIdis 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.
