Testování Axon 5 agregátů pomocí Spocku: Praktický průvodce

Event-sourcingový model Axon Frameworku se přirozeně snoubí s expresivním given-when-then stylem Spocku. Přesto většina příkladů online ukazuje JUnit. Tento průvodce pokrývá, jak testovat kompletní stavový automat Axon 5 agregátu pomocí Spocku a AxonTestFixture.
Doména: Agregát objednávky
Použijeme jednoduchý životní cyklus objednávky:
Agregát sleduje stav pomocí @EventSourcingHandler metod, které přehrávají uložené eventy:
@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; }
}Nastavení fixture
Axon 5 nahradil starý AggregateTestFixture novým AxonTestFixture postaveným na EventSourcingConfigurer. Nastavení žije ve Spock bloku setup():
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()
}
}Tři věci k zapamatování:
disableAxonServer()— fixture běží in-memory, žádný server není potřeba.registerIgnoredField()— command handlery volajíLocalDateTime.now(), takže přesné porovnání eventů je nemožné. Ignorování timestampových polí to obchází.cleanup()— vždy zastavte fixture, aby se uvolnily prostředky.
Testování vytvoření (bez předchozího stavu)
Testy vytvoření používají .noPriorActivity(), protože agregát ještě neexistuje:
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)
}Testování přechodů stavů pomocí .event()
Tady to začíná být zajímavé. Pro testování příkazu, který vyžaduje předchozí stav, přehráváte eventy přes .given().event(). Fixture tyto eventy prožene přes @EventSourcingHandler metody agregátu a rekonstruuje stav přesně tak, jak by to fungovalo v produkci.
// Helper - vytvoří znovupoužitelný event "objednávka existuje"
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()) // řetězení eventů pro sestavení stavu
.when()
.command(new ShipOrderCommand("order-1", "TRACK-12345"))
.then()
.success()
.eventsMatch({ events ->
events.size() == 1 &&
(events[0].payload() as OrderShippedEvent)
.trackingNumber() == "TRACK-12345"
})
}Klíčový poznatek: eventy se řetězí. Každé volání .event() přidává další event do historie agregátu. Fixture je přehraje v pořadí, takže po orderPlacedEvent() + orderConfirmedEvent() je pole status agregátu nastaveno na CONFIRMED — přesně to, co ShipOrderCommand vyžaduje.
Testování guard clause odmítnutí
Stavové guardy jsou nejcennější testy. Ověřují, že agregát odmítne příkazy, které nedávají smysl pro aktuální stav:
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)
}Každý test nastaví přesný stav, který by měl způsobit odmítnutí, a pak ověří, že agregát vyhodí výjimku. Žádné mocky, žádná databáze — čistá verifikace doménové logiky.
Testování stavu kolekcí
Agregáty často sledují množiny — účastníky, položky objednávky, aplikované kupóny. Ty vyžadují předchozí eventy, které kolekci naplní:
// Představte si, že agregát objednávky také sleduje aplikované slevové kódy
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)
}Fixture přehraje CouponAppliedEvent, který naplní množinu appliedCoupons v agregátu. Když přijde duplicitní příkaz, guard clause ho zachytí.
Test celého životního cyklu
Jeden integrační test, který projde celou happy path, dává jistotu, že stavový automat funguje 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
})
}Práce s injektovanými službami
Některé command handlery přijímají injektované služby (např. pricing service nebo validátor). Zaregistrujte je ve fixture:
def setup() {
def pricingService = Stub(PricingService) {
calculateTotal(_) >> new Money(99.99, "USD")
}
fixture = AxonTestFixture.with(configurer, customization ->
customization.disableAxonServer()
.registerInjectableResource(pricingService)
)
}Fixture injektuje stub do každého @CommandHandler, který deklaruje službu jako parametr. Díky tomu jsou testy agregátů rychlé a izolované.
Struktura komplexní testovací sady
Reálná specifikace agregátu by měla pokrýt každou šipku (i ne-šipku) ve stavovém diagramu. Organizujte testy do jasných sekcí:
class OrderAggregateSpec extends Specification {
// ... nastavení fixture ...
// ============== Testy vytvoření ==============
// Test validního vytvoření a validace vstupů
// ============== Happy-path přechody ==============
// CREATED -> confirm, CONFIRMED -> ship, atd.
// ============== Guard clause odmítnutí ==============
// Každá nevalidní kombinace stavu a příkazu
// ============== Testy stavu kolekcí ==============
// Duplikáty, limity kapacity, kontroly členství
// ============== Celý životní cyklus ==============
// End-to-end verifikace stavového automatu
}Pro agregát s N stavy a M příkazy budete chtít minimálně:
- M happy-path testů (jeden na příkaz z jeho validního zdrojového stavu)
- N x M testů odmítnutí (každý příkaz z každého nevalidního stavu)
- Edge case testy pro guard clause kolekcí (duplikáty, kapacita)
- 1 test celého cyklu pokrývající nejdelší validní cestu
Může to znít jako hodně, ale každý test má typicky 10-15 řádků s helper metodami a běží v milisekundách, protože nedochází k žádnému I/O.
Rychlý přehled
| Vzor | Kdy použít |
|---|---|
.noPriorActivity() | Příkazy vytvoření — agregát ještě neexistuje |
.event(e) | Jeden předchozí event pro nastavení stavu |
.event(e1).event(e2) | Řetězení eventů pro dosažení hlubších stavů |
.exception(Type) | Ověření guard clause odmítnutí |
.eventsMatch({ ... }) | Assertování typu a polí emitovaného eventu |
registerIgnoredField() | Přeskočení LocalDateTime.now() timestampů |
registerInjectableResource() | Stubování služeb injektovaných do handlerů |
Klíčový poznatek
Přístup .given().event() není testovací workaround — je to přesně tak, jak event sourcing funguje. V produkci se agregáty rekonstruují přehráváním jejich event streamu. Testovací fixture dělá totéž a pak spustí váš příkaz proti rekonstituovanému stavu. Pokud vaše testy používají tento vzor, testují stejnou kódovou cestu, která běží v produkci.
Titulní foto: Markus Spiske na Unsplash.


