Products Consulting About Blog Contact Us Česky
arrow_back Back to blog

Testing Axon 5 Aggregates with Spock: A Practical Guide

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:

stateDiagram-v2 [*] --> CREATED CREATED --> CONFIRMED: confirm CONFIRMED --> SHIPPED: ship SHIPPED --> DELIVERED: deliver CREATED --> CANCELLED: cancel CONFIRMED --> CANCELLED: cancel

The aggregate tracks status via @EventSourcingHandler methods that replay stored events:

Java
@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:

GROOVY
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:

  1. disableAxonServer() — the fixture runs in-memory, no server needed.
  2. registerIgnoredField() — command handlers call LocalDateTime.now(), making exact event matching impossible. Ignoring timestamp fields sidesteps this.
  3. cleanup() — always stop the fixture to release resources.

Testing Creation (No Prior State)

Creation tests use .noPriorActivity() since no aggregate exists yet:

GROOVY
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.

GROOVY
// 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:

GROOVY
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:

GROOVY
// 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:

GROOVY
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:

GROOVY
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:

GROOVY
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

PatternWhen 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.

More from the Blog