Domain Test Suite
wow-test runs aggregate and stateless saga specifications with an in-memory domain runtime. It verifies command decisions, domain events, event-sourced state, and commands produced by sagas without requiring a database or message broker.
Test boundary
A passing domain specification proves domain behavior only. It does not prove that KSP output is packaged, Spring is wired correctly, HTTP routes work, real storage recovers, or authorization is enforced. See Testing Wow Applications for those gates.
Completion signal
Every modeled invariant should have at least one success or rejection specification, and state transitions should assert both the event and sourced state. When the domain layer is complete, run its owning module's test/check, then move to application integration gates.
Installation
dependencies {
testImplementation("me.ahoo.wow:wow-test:${wowVersion}")
}dependencies {
testImplementation "me.ahoo.wow:wow-test:${wowVersion}"
}<dependency>
<groupId>me.ahoo.wow</groupId>
<artifactId>wow-test</artifactId>
<version>${wow.version}</version>
<scope>test</scope>
</dependency>Kotlin assertions use the FluentAssert extension from the project's test stack:
import me.ahoo.test.asserts.assertGiven → When → Expect
| Stage | Question to express | Common DSL |
|---|---|---|
| Given | What happened to the aggregate before now? | givenEvent, givenState, givenOwnerId, givenSpaceId, inject |
| When | What happens now? | whenCommand, or whenEvent for a saga |
| Expect | Does the result satisfy the invariant? | expectNoError, expectErrorType, expectEventType, expectState, expectCommand |
Prefer historical events for Given. givenState is useful when a test explicitly needs to start at a state version, but it bypasses event replay and cannot replace sourcing-behavior verification.
Aggregate Specifications: Assert Event and State Together
This minimal scenario comes from the current CartSpec. It starts with an uninitialized aggregate, sets the owner, executes an add-item command, and verifies the event, business state, and aggregate metadata together:
import me.ahoo.test.asserts.assert
import me.ahoo.wow.test.AggregateSpec
class CartSpec : AggregateSpec<Cart, CartState>({
on {
val ownerId = generateGlobalId()
val addCartItem = AddCartItem(
productId = "productId",
quantity = 1,
)
givenOwnerId(ownerId)
whenCommand(addCartItem) {
expectNoError()
expectEventType(CartItemAdded::class)
expectState {
items.assert().hasSize(1)
}
expectStateAggregate {
this.ownerId.assert().isEqualTo(ownerId)
}
}
}
})The event assertion proves the command decision; the state assertion proves the sourcing function applied that event correctly. Asserting only one side misses regressions on the other.
Rejection Paths
Rejection paths should assert a specific error and, where important, confirm that state or aggregate metadata did not advance. The current OrderSpec covers empty items, insufficient inventory, inconsistent prices, shipping before payment, and access after deletion.
fork("Ship Before Payment") {
val shipOrder = ShipOrder(stateAggregate.aggregateId.id)
whenCommand(shipOrder) {
expectErrorType(IllegalStateException::class)
expectState {
paidAmount.assert().isEqualTo(BigDecimal.ZERO)
status.assert().isEqualTo(OrderStatus.CREATED)
}
}
}Do not reduce every failure to expectError(). When the error type is part of the business contract, use expectErrorType(...) so the specification distinguishes rejection reasons.
Branches and Reference Points
fork continues from an already verified state. It fits payment, shipment, and receipt after order creation, as well as invalid transitions from the same starting point. Each branch has independent subsequent state and does not contaminate sibling branches.
Branch directly from the current Expect stage:
fork(name = "Remove CartItem") {
whenCommand(RemoveCartItem(setOf(addCartItem.productId))) {
expectEventType(CartItemRemoved::class)
expectState {
items.assert().isEmpty()
}
}
}When a later scenario must branch from the same point, call ref("AggregateDeleted") first and then use top-level fork(ref = "AggregateDeleted", ...). A reference should represent a verified business state, not merely save a few setup lines.
fork(ref = "AggregateDeleted", name = "Recover") {
whenCommand(DefaultRecoverAggregate) {
expectNoError()
expectStateAggregate {
deleted.assert().isFalse()
}
fork(name = "Recover Again") {
whenCommand(DefaultRecoverAggregate) {
expectErrorType(IllegalStateException::class)
}
}
}
}Inject Domain Dependencies
When a command handler depends on a domain specification service, register a test implementation with inject. The current OrderSpec injects inventory and pricing services into DefaultCreateOrderSpec to cover success, insufficient inventory, and inconsistent prices.
inject {
register(DefaultCreateOrderSpec(inventoryService, pricingService))
}
whenCommand(CreateOrder(orderItems, SHIPPING_ADDRESS, false)) {
expectNoError()
expectEventType(OrderCreated::class)
expectState {
status.assert().isEqualTo(OrderStatus.CREATED)
totalAmount.assert().isEqualTo(totalAmount)
}
}These are test implementations at the domain boundary. Real network clients, databases, and brokers belong in application integration tests, not this layer.
Stateless Saga Specifications
For SagaSpec, When is an input event and Expect is the command sent by the saga. The current CartSagaSpec verifies that creating an order from a cart removes the corresponding products:
import me.ahoo.test.asserts.assert
import me.ahoo.wow.test.SagaSpec
class CartSagaSpec : SagaSpec<CartSaga>({
on {
val ownerId = generateGlobalId()
val orderItem = OrderItem(
id = generateGlobalId(),
productId = generateGlobalId(),
price = BigDecimal.valueOf(10),
quantity = 10,
)
whenEvent(
event = mockk<OrderCreated> {
every { items } returns listOf(orderItem)
every { fromCart } returns true
},
ownerId = ownerId,
) {
expectCommandType(RemoveCartItem::class)
expectCommand<RemoveCartItem> {
aggregateId.id.assert().isEqualTo(ownerId)
body.productIds.assert().hasSize(1)
body.productIds.assert().first().isEqualTo(orderItem.productId)
}
}
}
})Use expectNoCommand() for the corresponding negative path, such as OrderCreated.fromCart == false. A saga specification verifies command intent and content; broker redelivery and external-side-effect idempotency still require real-adapter tests.
Choose the Narrowest Assertion
| Goal | DSL |
|---|---|
| No error / a specific error | expectNoError() / expectErrorType(...) |
| Event count, order, or type | expectEventCount, expectEventIterator, expectEventType |
| Event-body fields | expectEventBody<E> { ... } |
| Business state | expectState { ... } |
| Aggregate metadata such as owner, version, or deleted flag | expectStateAggregate { ... } |
| Saga command count, type, or content | expectCommandCount, expectCommandType, expectCommand<C> |
Assert business-observable results instead of copying framework internals. Use .assert() consistently for Kotlin values rather than mixing assertion styles in the same specification suite.
Reading Coverage Evidence
The current repository configures a 0.8 minimum for :example-domain:jacocoTestCoverageVerification, and that task depends on test and report generation. The threshold runs only when :example-domain:jacocoTestCoverageVerification is invoked explicitly; neither the current :example-domain:check nor the CI workflows attach it automatically. This is an optional repository coverage gate, not a coverage level automatically guaranteed by wow-test or a number every application must copy.
Old documentation screenshots, historical coverage figures, or anecdotal defect data describe only their original samples. Evaluate a current change using the current test output, current coverage report, and the application's own threshold.
Run and Move to the Next Layer
Verify the example and DSL in this repository with:
./gradlew :wow-test:check :example-domain:check \
:example-domain:jacocoTestCoverageVerificationBusiness applications should substitute their own domain-module path and include its verification task only when the project actually configures a threshold. After this command passes, move to Testing Wow Applications for generated metadata, runtime wiring, HTTP, real adapters, restart recovery, and security negatives. When changing the Wow framework itself, use the repository tasks in Framework Tests and Benchmarks.