Testing Axon 5 Aggregates with Spock: A Practical Guide

Axon Framework’s event-sourcing model pairs naturally with Spock’s expressive given-when-then style. Yet most examples online show JUnit. This guide covers how to test an Axon 5 aggregate’s full state machine using Spock and AxonTestFixture.
The Domain: An Order Aggregate
We’ll use a simple order lifecycle:
The aggregate tracks status via @EventSourcingHandler methods that replay stored events:
@EventSourced(idType = String.class)
@EventSourcedEntity(tagKey = "orderId")
public class OrderAggregate {
private String orderId;
private OrderStatus status;
private String customerId;
@EntityCreator
public OrderAggregate() {}
@CommandHandler
public static void create(PlaceOrderCommand cmd, EventAppender appender) {
if (cmd.items().isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
appender.append(new OrderPlacedEvent(
cmd.orderId(), cmd.customerId(), cmd.items(), LocalDateTime.now()));
}
@CommandHandler
public void handle(ConfirmOrderCommand cmd, EventAppender appender) {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Can only confirm a newly created order");
}
appender.append(new OrderConfirmedEvent(cmd.orderId(), LocalDateTime.now()));
}
@CommandHandler
public void handle(ShipOrderCommand cmd, EventAppender appender) {
if (this.status != OrderStatus.CONFIRMED) {
throw new IllegalStateException("Can only ship a confirmed order");
}
appender.append(new OrderShippedEvent(
cmd.orderId(), cmd.trackingNumber(), LocalDateTime.now()));
}
@CommandHandler
public void handle(CancelOrderCommand cmd, EventAppender appender) {
if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.DELIVERED
|| this.status == OrderStatus.CANCELLED) {
throw new IllegalStateException(
"Cannot cancel order in status: " + this.status);
}
appender.append(new OrderCancelledEvent(
cmd.orderId(), cmd.reason(), LocalDateTime.now()));
}
@EventSourcingHandler
public void on(OrderPlacedEvent e) {
this.orderId = e.orderId();
this.customerId = e.customerId();
this.status = OrderStatus.CREATED;
}
@EventSourcingHandler
public void on(OrderConfirmedEvent e) { this.status = OrderStatus.CONFIRMED; }
@EventSourcingHandler
public void on(OrderShippedEvent e) { this.status = OrderStatus.SHIPPED; }
@EventSourcingHandler
public void on(OrderCancelledEvent e) { this.status = OrderStatus.CANCELLED; }
}Setting Up the Fixture
Axon 5 replaced the old AggregateTestFixture with AxonTestFixture backed by EventSourcingConfigurer. Setup lives in Spock’s setup() block:
class OrderAggregateSpec extends Specification {
AxonTestFixture fixture
def setup() {
EventSourcingConfigurer configurer = EventSourcingConfigurer.create()
.registerEntity(
EventSourcedEntityModule.autodetected(
String.class, OrderAggregate.class)
)
fixture = AxonTestFixture.with(configurer, customization ->
customization.disableAxonServer()
.registerIgnoredField(OrderPlacedEvent.class, "placedAt")
.registerIgnoredField(OrderConfirmedEvent.class, "confirmedAt")
.registerIgnoredField(OrderShippedEvent.class, "shippedAt")
.registerIgnoredField(OrderCancelledEvent.class, "cancelledAt")
)
}
def cleanup() {
fixture?.stop()
}
}Three things to note:
disableAxonServer()— the fixture runs in-memory, no server needed.registerIgnoredField()— command handlers callLocalDateTime.now(), making exact event matching impossible. Ignoring timestamp fields sidesteps this.cleanup()— always stop the fixture to release resources.
Testing Creation (No Prior State)
Creation tests use .noPriorActivity() since no aggregate exists yet:
def "should place an order with items"() {
given:
def command = new PlaceOrderCommand("order-1", "cust-1", ["SKU-100", "SKU-200"])
expect:
fixture.given()
.noPriorActivity()
.when()
.command(command)
.then()
.success()
.eventsMatch({ events ->
events.size() == 1 &&
events[0].payload() instanceof OrderPlacedEvent &&
(events[0].payload() as OrderPlacedEvent).orderId() == "order-1" &&
(events[0].payload() as OrderPlacedEvent).items().size() == 2
})
}
def "should reject order with no items"() {
expect:
fixture.given()
.noPriorActivity()
.when()
.command(new PlaceOrderCommand("order-1", "cust-1", []))
.then()
.exception(IllegalArgumentException)
}Testing State Transitions with .event()
This is where it gets interesting. To test a command that requires prior state, you replay events through .given().event(). The fixture feeds these events through the aggregate’s @EventSourcingHandler methods, rebuilding state exactly as it would in production.
// Helper - builds a reusable "order exists" event
private OrderPlacedEvent orderPlacedEvent(String orderId = "order-1") {
new OrderPlacedEvent(orderId, "cust-1", ["SKU-100"], LocalDateTime.now())
}
private OrderConfirmedEvent orderConfirmedEvent(String orderId = "order-1") {
new OrderConfirmedEvent(orderId, LocalDateTime.now())
}
def "should confirm a newly created order"() {
expect:
fixture.given()
.event(orderPlacedEvent())
.when()
.command(new ConfirmOrderCommand("order-1"))
.then()
.success()
.eventsMatch({ events ->
events.size() == 1 &&
events[0].payload() instanceof OrderConfirmedEvent
})
}
def "should ship a confirmed order"() {
expect:
fixture.given()
.event(orderPlacedEvent())
.event(orderConfirmedEvent()) // chain events to build up state
.when()
.command(new ShipOrderCommand("order-1", "TRACK-12345"))
.then()
.success()
.eventsMatch({ events ->
events.size() == 1 &&
(events[0].payload() as OrderShippedEvent)
.trackingNumber() == "TRACK-12345"
})
}The key insight: events chain. Each .event() call adds another event to the aggregate’s history. The fixture replays them in order, so after orderPlacedEvent() + orderConfirmedEvent(), the aggregate’s status field is CONFIRMED — exactly what ShipOrderCommand requires.
Testing Guard Clause Rejections
State guards are the most valuable tests. They verify your aggregate rejects commands that don’t make sense for the current state:
def "should reject confirming an already confirmed order"() {
expect:
fixture.given()
.event(orderPlacedEvent())
.event(orderConfirmedEvent())
.when()
.command(new ConfirmOrderCommand("order-1"))
.then()
.exception(IllegalStateException)
}
def "should reject shipping an unconfirmed order"() {
expect:
fixture.given()
.event(orderPlacedEvent())
.when()
.command(new ShipOrderCommand("order-1", "TRACK-12345"))
.then()
.exception(IllegalStateException)
}
def "should reject cancelling a shipped order"() {
expect:
fixture.given()
.event(orderPlacedEvent())
.event(orderConfirmedEvent())
.event(new OrderShippedEvent(
"order-1", "TRACK-12345", LocalDateTime.now()))
.when()
.command(new CancelOrderCommand("order-1", "Changed my mind"))
.then()
.exception(IllegalStateException)
}Each test sets up the exact state that should cause the rejection, then verifies the aggregate throws. No mocks, no database — pure domain logic verification.
Testing Collection State
Aggregates often track sets — participants, line items, applied coupons. These require prior events that populate the collection:
// Imagine the order aggregate also tracks applied coupon codes
def "should reject applying the same coupon twice"() {
expect:
fixture.given()
.event(orderPlacedEvent())
.event(new CouponAppliedEvent("order-1", "SAVE20"))
.when()
.command(new ApplyCouponCommand("order-1", "SAVE20"))
.then()
.exception(IllegalStateException)
}The fixture replays the CouponAppliedEvent, which populates the aggregate’s appliedCoupons set. When the duplicate command arrives, the guard clause catches it.
Full Lifecycle Test
One integration-style test that walks through the entire happy path gives confidence that the state machine works end to end:
def "should complete full order lifecycle"() {
expect:
fixture.given()
.event(orderPlacedEvent())
.event(orderConfirmedEvent())
.event(new OrderShippedEvent(
"order-1", "TRACK-12345", LocalDateTime.now()))
.when()
.command(new DeliverOrderCommand("order-1"))
.then()
.success()
.eventsMatch({ events ->
events.size() == 1 &&
events[0].payload() instanceof OrderDeliveredEvent
})
}Dealing with Injected Services
Some command handlers take injected services (e.g., a pricing service or validator). Register them with the fixture:
def setup() {
def pricingService = Stub(PricingService) {
calculateTotal(_) >> new Money(99.99, "USD")
}
fixture = AxonTestFixture.with(configurer, customization ->
customization.disableAxonServer()
.registerInjectableResource(pricingService)
)
}The fixture injects the stub into any @CommandHandler that declares the service as a parameter. This keeps aggregate tests fast and isolated.
Structuring a Comprehensive Test Suite
A real aggregate spec should cover every arrow (and non-arrow) in the state diagram. Organize tests into clear sections:
class OrderAggregateSpec extends Specification {
// ... fixture setup ...
// ============== Creation Tests ==============
// Test valid creation and input validation
// ============== Happy-path Transitions ==============
// CREATED -> confirm, CONFIRMED -> ship, etc.
// ============== Guard Clause Rejections ==============
// Every invalid state + command combination
// ============== Collection State Tests ==============
// Duplicates, capacity limits, membership checks
// ============== Full Lifecycle ==============
// End-to-end state machine verification
}For an aggregate with N states and M commands, you’ll want at minimum:
- M happy-path tests (one per command from its valid source state)
- N x M rejection tests (each command from each invalid state)
- Edge case tests for collection guards (duplicates, capacity)
- 1 full-cycle test covering the longest valid path
This may seem like a lot, but each test is typically 10-15 lines with helper methods, and they run in milliseconds since there’s no I/O.
Quick Reference
| Pattern | When to use |
|---|---|
.noPriorActivity() | Creation commands — no aggregate exists yet |
.event(e) | Single prior event to establish state |
.event(e1).event(e2) | Chain events to reach deeper states |
.exception(Type) | Verify guard clause rejections |
.eventsMatch({ ... }) | Assert emitted event type and fields |
registerIgnoredField() | Skip LocalDateTime.now() timestamps |
registerInjectableResource() | Stub services injected into handlers |
The Key Takeaway
The .given().event() approach is not a test workaround — it’s how event sourcing actually works. In production, aggregates are rebuilt by replaying their event stream. The test fixture does the same thing, then executes your command against the reconstituted state. If your tests use this pattern, they’re testing the same code path that runs in production.
Cover photo by Markus Spiske on Unsplash.


