62 Test-first Development für EDA

Angenommen Sie beginnen ein neues Feature in Ihrer Event-Driven Architecture. Womit fangen Sie an? Mit dem Code? Mit der Architektur? Oder vielleicht… mit den Tests?

Test-first Development wendet das bewährte TDD-Prinzip auf Event-Driven Architecture an. Aber wie schreibt man Tests für Events, die noch nicht existieren? Wie definiert man das Verhalten asynchroner Systeme, bevor man sie implementiert?

Diese Fragen führen uns zum Kern des testgetriebenen Designs in Event-Driven Systemen: Erst verstehen, was passieren soll, dann implementieren, wie es passiert.

62.1 TDD mit Events

62.1.1 Der Event-First Mindset

Bevor wir in die Implementierung einsteigen: Welche Geschäftsereignisse wollen wir eigentlich modellieren? Welche Events sollte unser System produzieren und konsumieren?

Denken Sie an einen neuen Feature-Request: “Als Kunde möchte ich benachrichtigt werden, wenn meine Bestellung versendet wurde.”

Welche Events fallen Ihnen spontan ein? Welche Services wären beteiligt? Wie würde der Event-Flow aussehen?

Event Storming im Test:

// Red Phase: Test für noch nicht existierende Events
@Test
void shouldNotifyCustomerWhenOrderShipped() {
    // Given - Was sind unsere Annahmen?
    String customerId = "customer-123";
    String orderId = "order-456";
    
    CustomerNotificationAssertion notification = new CustomerNotificationAssertion(customerId);
    
    // When - Welches Event löst die Benachrichtigung aus?
    OrderShippedEvent shippedEvent = OrderShippedEvent.builder()
        .orderId(orderId)
        .customerId(customerId)
        .trackingNumber("TRACK-789")
        .estimatedDelivery(LocalDate.now().plusDays(2))
        .build();
        
    eventPublisher.publish("order.shipped.v1", shippedEvent);
    
    // Then - Was erwarten wir als Ergebnis?
    notification.waitForNotification(Duration.ofSeconds(5))
        .assertNotificationSent()
        .assertNotificationType(NotificationType.ORDER_SHIPPED)
        .assertContainsTrackingNumber("TRACK-789");
}

Können Sie bereits erkennen, welche Komponenten wir implementieren müssen? Welche Events noch nicht existieren? Welche Services erstellt werden müssen?

62.1.2 Red-Green-Refactor mit Events

Wie wenden wir den klassischen TDD-Zyklus auf Event-Driven Architecture an?

Red Phase - Test schlägt fehl:

// Compilation Error: OrderShippedEvent existiert noch nicht
// Compilation Error: CustomerNotificationAssertion existiert noch nicht
// Compilation Error: NotificationType.ORDER_SHIPPED existiert noch nicht

Green Phase - Minimale Implementierung:

// Erst die Event-Struktur
public class OrderShippedEvent {
    private String orderId;
    private String customerId;
    private String trackingNumber;
    private LocalDate estimatedDelivery;
    // Constructor, getters, setters...
}

// Dann der Test-Helper
public class CustomerNotificationAssertion {
    private final String customerId;
    private final List<Notification> receivedNotifications = new ArrayList<>();
    
    public CustomerNotificationAssertion(String customerId) {
        this.customerId = customerId;
    }
    
    public CustomerNotificationAssertion waitForNotification(Duration timeout) {
        // Polling-Mechanismus für asynchrone Verifikation
        return this;
    }
    
    public CustomerNotificationAssertion assertNotificationSent() {
        assertThat(receivedNotifications).isNotEmpty();
        return this;
    }
}

// Schließlich der Service
@Service
public class CustomerNotificationService {
    
    @EventListener
    public void handleOrderShipped(OrderShippedEvent event) {
        // Minimale Implementierung für Green Phase
        Notification notification = new Notification(
            event.getCustomerId(),
            NotificationType.ORDER_SHIPPED,
            "Your order has been shipped!"
        );
        
        sendNotification(notification);
    }
}

Welche Aspekte haben wir in der Green Phase noch nicht berücksichtigt? Was würden Sie als nächstes refactoren?

Refactor Phase - Design verbessern:

// Verbessertes Event Design
public class OrderShippedEvent implements CorrelatedEvent {
    private String eventId = UUID.randomUUID().toString();
    private String correlationId;
    private Instant timestamp = Instant.now();
    private OrderShippedData data;
    
    @Builder
    public static class OrderShippedData {
        private String orderId;
        private String customerId;
        private String trackingNumber;
        private LocalDate estimatedDelivery;
        private ShippingProvider shippingProvider;
    }
}

// Robustere Notification-Implementierung
@Service
public class CustomerNotificationService {
    
    private final NotificationTemplateEngine templateEngine;
    private final NotificationChannel notificationChannel;
    
    @EventListener
    public void handleOrderShipped(OrderShippedEvent event) {
        try {
            NotificationTemplate template = templateEngine.getTemplate(NotificationType.ORDER_SHIPPED);
            Notification notification = template.createNotification(event.getData());
            
            notificationChannel.send(notification);
            
            // Event für erfolgreiche Benachrichtigung
            publishNotificationSentEvent(event, notification);
            
        } catch (Exception e) {
            // Event für fehlgeschlagene Benachrichtigung
            publishNotificationFailedEvent(event, e);
        }
    }
}

62.1.3 Asynchrone TDD-Patterns

Wie schreiben wir Tests für asynchrone Event-Processing? Welche Herausforderungen entstehen durch die Asynchronität?

Temporal Assertions für Events:

@Test
void shouldProcessEventsInCorrectOrder() throws Exception {
    // Given - Definiere erwartete Event-Reihenfolge
    EventSequenceAssertion sequenceAssertion = new EventSequenceAssertion()
        .expectEvent(PaymentProcessedEvent.class)
        .then()
        .expectEvent(InventoryReservedEvent.class)
        .then()
        .expectEvent(OrderShippedEvent.class)
        .withinTimeout(Duration.ofMinutes(2));
    
    // When - Trigger initial event
    OrderPlacedEvent orderEvent = anOrderPlacedEvent().build();
    eventPublisher.publish("order.placed.v1", orderEvent);
    
    // Then - Asynchrone Verifikation
    sequenceAssertion.waitForCompletion();
}

public class EventSequenceAssertion {
    private final List<Class<?>> expectedSequence = new ArrayList<>();
    private final List<Object> receivedEvents = new ArrayList<>();
    private Duration timeout = Duration.ofSeconds(30);
    
    public EventSequenceAssertion expectEvent(Class<?> eventType) {
        expectedSequence.add(eventType);
        return this;
    }
    
    public EventSequenceAssertion then() {
        return this; // Fluent interface für Lesbarkeit
    }
    
    public void waitForCompletion() throws InterruptedException {
        await().atMost(timeout).until(() -> {
            return receivedEvents.size() >= expectedSequence.size() &&
                   eventsMatchExpectedSequence();
        });
    }
}

Welche Alternativen sehen Sie zu diesem Ansatz? Wie würden Sie mit Events umgehen, die in unterschiedlicher Reihenfolge ankommen können?

62.2 Specification by Example

62.2.1 Event-basierte Specifications

Specification by Example übersetzt Geschäftsanforderungen in ausführbare Tests. Aber wie funktioniert das mit Events?

Betrachten Sie diese Geschäftsregel: “Wenn ein Kunde eine Bestellung mit mehr als 100€ Wert aufgibt, soll er automatisch kostenlosen Premium-Versand erhalten.”

Wie würden Sie diese Regel als Event-basierte Specification formulieren?

Business Rule als Test:

@Test
void shouldUpgradeToFreeShippingForHighValueOrders() {
    // Given - Business Context
    BigDecimal highValue = BigDecimal.valueOf(150.00);
    String customerId = "premium-customer-123";
    
    ShippingUpgradeAssertion shippingAssertion = new ShippingUpgradeAssertion();
    
    // When - Business Event occurs
    OrderPlacedEvent highValueOrder = anOrderPlacedEvent()
        .withCustomerId(customerId)
        .withTotalAmount(highValue)
        .withShippingMethod(ShippingMethod.STANDARD)
        .build();
        
    eventPublisher.publish("order.placed.v1", highValueOrder);
    
    // Then - Business Rule is applied
    shippingAssertion.waitForUpgrade(Duration.ofSeconds(10))
        .assertShippingMethodUpgraded(ShippingMethod.PREMIUM)
        .assertShippingCostWaived()
        .assertCustomerNotified();
}

Specification Table Testing:

@ParameterizedTest
@CsvSource({
    "50.00,  STANDARD, STANDARD, false, Standard shipping for low value",
    "99.99,  STANDARD, STANDARD, false, No upgrade just below threshold", 
    "100.00, STANDARD, PREMIUM,  true,  Upgrade at exact threshold",
    "150.00, STANDARD, PREMIUM,  true,  Upgrade for high value",
    "200.00, EXPRESS,  EXPRESS,  false, No change for already premium shipping"
})
void shouldApplyShippingRulesBasedOnOrderValue(
    BigDecimal orderValue,
    ShippingMethod originalMethod,
    ShippingMethod expectedMethod,
    boolean expectUpgrade,
    String scenario) {
    
    // Given
    ShippingUpgradeAssertion assertion = new ShippingUpgradeAssertion();
    
    // When  
    OrderPlacedEvent orderEvent = anOrderPlacedEvent()
        .withTotalAmount(orderValue)
        .withShippingMethod(originalMethod)
        .build();
        
    eventPublisher.publish("order.placed.v1", orderEvent);
    
    // Then
    if (expectUpgrade) {
        assertion.waitForUpgrade(Duration.ofSeconds(5))
            .assertShippingMethodUpgraded(expectedMethod);
    } else {
        assertion.waitForProcessing(Duration.ofSeconds(5))
            .assertNoShippingUpgrade();
    }
}

Welche weiteren Geschäftsregeln fallen Ihnen ein, die sich gut als Event-basierte Specifications testen ließen?

62.2.2 Living Documentation durch Tests

Wie können Tests gleichzeitig als lebende Dokumentation für Event-Flows dienen?

Self-Documenting Event Tests:

@Nested
@DisplayName("Order Processing - Complete Flow")
class OrderProcessingFlowSpecification {
    
    @Test
    @DisplayName("When customer places valid order, system should process payment, reserve inventory and ship order")
    void completeHappyPathFlow() {
        given("a customer with valid payment method")
            .and("sufficient inventory is available")
            .when("customer places an order")
            .then("payment should be processed")
            .and("inventory should be reserved")  
            .and("order should be shipped")
            .and("customer should be notified");
    }
    
    @Test
    @DisplayName("When payment fails, system should cancel inventory reservation and notify customer")
    void paymentFailureFlow() {
        given("a customer with invalid payment method")
            .and("sufficient inventory is available")
            .when("customer places an order")
            .then("payment should fail")
            .and("inventory reservation should be cancelled")
            .and("customer should be notified of payment failure")
            .and("order should be marked as failed");
    }
}

Python Specification Style:

class TestOrderProcessingSpecification:
    
    def test_high_value_order_gets_free_shipping(self):
        """
        Specification: Orders over €100 receive free premium shipping
        
        Given: Customer places order worth €150
        When: Order processing begins  
        Then: Shipping method is upgraded to premium
        And: Shipping cost is waived
        And: Customer is notified of the upgrade
        """
        # Given
        order_value = Decimal('150.00')
        shipping_assertion = ShippingUpgradeAssertion()
        
        # When
        order_event = OrderPlacedEventBuilder() \
            .with_total_amount(order_value) \
            .with_shipping_method(ShippingMethod.STANDARD) \
            .build()
            
        self.event_publisher.publish("order.placed.v1", order_event)
        
        # Then
        shipping_assertion.wait_for_upgrade(timeout=timedelta(seconds=10)) \
            .assert_shipping_upgraded(ShippingMethod.PREMIUM) \
            .assert_shipping_cost_waived() \
            .assert_customer_notified()

Wie würden Sie sicherstellen, dass diese Tests auch für fachliche Stakeholder verständlich bleiben?

62.3 Testing Anti-patterns

62.3.1 Was Sie in Event-Driven Tests vermeiden sollten

Welche Fallen haben Sie beim Testen von Event-Driven Systemen bereits erlebt? Welche Probleme sind Ihnen begegnet?

Anti-Pattern: Overly Coupled Event Tests

// SCHLECHT: Test ist an konkrete Infrastruktur gekoppelt
@Test
void badEventTest() {
    // Hard-coded Kafka Details
    KafkaProducer<String, OrderPlacedEvent> producer = new KafkaProducer<>(
        Map.of(
            "bootstrap.servers", "localhost:9092",
            "key.serializer", "org.apache.kafka.common.serialization.StringSerializer"
        )
    );
    
    // Direct Kafka interaction in test
    producer.send(new ProducerRecord<>("order.placed.v1", orderEvent));
    
    // Poll messages directly
    KafkaConsumer<String, PaymentProcessedEvent> consumer = new KafkaConsumer<>(consumerProps);
    ConsumerRecords<String, PaymentProcessedEvent> records = consumer.poll(Duration.ofSeconds(5));
    
    // Test ist fragil und schwer zu maintainen
}

// BESSER: Abstrahierte Event-Operationen
@Test
void betterEventTest() {
    // When
    orderService.placeOrder(orderRequest);
    
    // Then
    paymentEventAssertion.waitForPaymentProcessed(Duration.ofSeconds(5))
        .assertPaymentAmount(expectedAmount);
}

Warum ist der erste Ansatz problematisch? Welche Probleme entstehen bei Änderungen der Infrastruktur?

Anti-Pattern: Non-Deterministic Event Timing

// SCHLECHT: Race Conditions in Tests
@Test
void badTimingTest() {
    orderService.placeOrder(orderRequest);
    
    // Magische Sleep-Zeiten
    Thread.sleep(3000);
    
    // Annahme über Event-Timing
    PaymentProcessedEvent event = paymentEventCollector.getLastEvent();
    assertThat(event).isNotNull(); // Kann fehlschlagen bei langsamer Verarbeitung
}

// BESSER: Explizite Synchronisation
@Test
void betterTimingTest() {
    PaymentEventAssertion assertion = new PaymentEventAssertion();
    
    orderService.placeOrder(orderRequest);
    
    // Warten auf tatsächliches Event
    assertion.waitForPaymentProcessed(Duration.ofSeconds(10))
        .assertPaymentSuccessful();
}

Anti-Pattern: Giant Event Tests

// SCHLECHT: Ein Test testet zu viel
@Test
void badGiantTest() {
    // Test versucht gesamten E-Commerce Flow zu testen
    // 200+ Zeilen Code
    // Multiple Business Rules
    // Verschiedene Error Cases
    // Schwer zu debuggen bei Fehlschlag
}

// BESSER: Focused Event Tests
@Test
void testPaymentProcessing() {
    // Fokus nur auf Payment-Verarbeitung
}

@Test  
void testInventoryReservation() {
    // Fokus nur auf Inventory-Logik
}

@Test
void testShippingUpgrade() {
    // Fokus nur auf Shipping-Rules
}

Welche weiteren Anti-Patterns sind Ihnen in Event-Driven Tests begegnet?

Anti-Pattern: Event Data Mutation in Tests

// SCHLECHT: Test verändert Event-Daten
@Test
void badMutationTest() {
    OrderPlacedEvent event = getTestEvent();
    event.setOrderId("modified-id"); // Test verändert Event-State
    event.getData().setTotalAmount(BigDecimal.valueOf(999.99));
    
    // Andere Tests könnten beeinflusst werden
    processEvent(event);
}

// BESSER: Immutable Test Events
@Test
void betterImmutableTest() {
    OrderPlacedEvent event = anOrderPlacedEvent()
        .withOrderId("test-specific-id")
        .withTotalAmount(BigDecimal.valueOf(123.45))
        .build(); // Jeder Test bekommt frische Event-Instanz
    
    processEvent(event);
}

62.3.2 Best Practices für Event-Test Design

Basierend auf den Anti-Patterns: Welche Prinzipien würden Sie für gute Event-Tests ableiten?

Event Test Design Principles:

Prinzip Warum wichtig? Implementierung
Event Isolation Tests beeinflussen sich nicht Fresh Event instances per test
Temporal Decoupling Keine Race Conditions Explicit waiting mechanisms
Infrastructure Abstraction Portabilität und Wartbarkeit Event Publisher/Consumer abstractions
Single Responsibility Debuggability One business rule per test
Deterministic Assertions Verlässliche Tests No timing assumptions

Template für robuste Event Tests:

@Test
void shouldFollowEventTestTemplate() {
    // Arrange - Setup test state
    String correlationId = "test-correlation-" + UUID.randomUUID();
    EventAssertion eventAssertion = new EventAssertion(correlationId);
    
    TestEvent triggerEvent = aTestEvent()
        .withCorrelationId(correlationId)
        .withSpecificTestData()
        .build();
    
    // Act - Trigger event processing
    eventPublisher.publish("test.topic.v1", triggerEvent);
    
    // Assert - Verify expected outcomes
    eventAssertion.waitForCompletion(Duration.ofSeconds(10))
        .assertExpectedEventsReceived()
        .assertCorrectEventOrder()
        .assertBusinessRulesApplied()
        .assertNoUnexpectedSideEffects();
}

Welche dieser Prinzipien wären in Ihrem aktuellen Projekt am wichtigsten? Wo sehen Sie die größten Verbesserungsmöglichkeiten?

Test-first Development in Event-Driven Architecture verlangt ein Umdenken: Von synchronen Assertions zu asynchronen Erwartungen, von direkter Kontrolle zu eventbasierter Koordination. Der Schlüssel liegt darin, die Geschäftslogik durch Events zu spezifizieren, bevor man sie implementiert - und dabei die besonderen Herausforderungen asynchroner Systeme von Anfang an mitzudenken.

Welchen Aspekt von TDD mit Events würden Sie als nächstes in Ihrem Team einführen? Mit welchem konkreten Test würden Sie beginnen?