Produkty Poradenství O nás Blog Kontakt English
arrow_back Zpět na blog

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

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:

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

Agregát sleduje stav pomocí @EventSourcingHandler metod, které přehrávají uložené eventy:

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; }
}

Nastavení fixture

Axon 5 nahradil starý AggregateTestFixture novým AxonTestFixture postaveným na EventSourcingConfigurer. Nastavení žije ve Spock bloku setup():

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()
    }
}

Tři věci k zapamatování:

  1. disableAxonServer() — fixture běží in-memory, žádný server není potřeba.
  2. registerIgnoredField() — command handlery volají LocalDateTime.now(), takže přesné porovnání eventů je nemožné. Ignorování timestampových polí to obchází.
  3. 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:

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)
}

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.

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

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)
}

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

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

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
            })
}

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:

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

GROOVY
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

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

Další z blogu