58 Contract Testing auf Event-Ebene

Während Unit Tests einzelne Komponenten isoliert testen, fokussiert Contract Testing auf die Schnittstellen zwischen Services. In Event-Driven Architecture bedeutet das: Testen der Event-Formate und -Semantik zwischen Producer und Consumer, ohne beide Services gleichzeitig starten zu müssen.

Contract Testing löst ein fundamentales Problem verteilter Systeme: Wie stellen wir sicher, dass der PaymentService die Events versteht, die der OrderService produziert - ohne beide Services zu koppeln?

58.1 Schema-based Testing

58.1.1 Event Schema als Vertrag

Events funktionieren als implizite API zwischen Services. Ihr Schema definiert den Vertrag:

OrderPlaced Event Schema (JSON Schema):

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "OrderPlacedEvent",
  "type": "object",
  "required": ["eventId", "eventType", "timestamp", "data"],
  "properties": {
    "eventId": {"type": "string", "format": "uuid"},
    "eventType": {"type": "string", "enum": ["OrderPlaced"]},
    "timestamp": {"type": "string", "format": "date-time"},
    "version": {"type": "string", "enum": ["v1"]},
    "data": {
      "type": "object",
      "required": ["orderId", "customerId", "totalAmount"],
      "properties": {
        "orderId": {"type": "string", "format": "uuid"},
        "customerId": {"type": "string", "format": "uuid"},
        "totalAmount": {"type": "number", "minimum": 0},
        "currency": {"type": "string", "enum": ["EUR", "USD"]}
      }
    }
  }
}

58.1.2 Schema-Validierung im Producer Test

Der Producer muss beweisen, dass er schema-konforme Events erzeugt:

Spring Boot Schema Test:

@Test
void producedEventsShouldMatchSchema() throws Exception {
    // Given
    JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
    JsonSchema schema = factory.getSchema(getClass().getResourceAsStream("/schemas/order-placed-v1.json"));
    
    CreateOrderRequest request = aValidCreateOrderRequest().build();
    
    // When
    ArgumentCaptor<OrderPlacedEvent> eventCaptor = ArgumentCaptor.forClass(OrderPlacedEvent.class);
    orderService.placeOrder(request);
    verify(eventPublisher).publish(eq("order.placed.v1"), eventCaptor.capture());
    
    // Then
    String eventJson = objectMapper.writeValueAsString(eventCaptor.getValue());
    Set<ValidationMessage> errors = schema.validate(objectMapper.readTree(eventJson));
    
    assertThat(errors).isEmpty();
}

Python Schema Test:

import jsonschema

def test_produced_events_match_schema():
    # Given
    with open('schemas/order-placed-v1.json') as f:
        schema = json.load(f)
    
    mock_publisher = Mock()
    order_service = OrderService(mock_publisher)
    request = a_valid_create_order_request()
    
    # When
    order_service.place_order(request)
    
    # Then
    _, event = mock_publisher.publish.call_args[0]
    event_dict = event.to_dict()
    
    # Keine Exception = Schema valide
    jsonschema.validate(event_dict, schema)

58.1.3 Schema-Validierung im Consumer Test

Der Consumer muss beweisen, dass er schema-konforme Events verarbeiten kann:

Consumer Schema Test:

@Test
void consumerShouldHandleSchemaCompliantEvents() throws Exception {
    // Given - Event aus Schema generieren
    String validEventJson = """
        {
          "eventId": "550e8400-e29b-41d4-a716-446655440000",
          "eventType": "OrderPlaced",
          "timestamp": "2025-01-15T10:30:00Z",
          "version": "v1",
          "data": {
            "orderId": "order-123",
            "customerId": "customer-456", 
            "totalAmount": 99.99,
            "currency": "EUR"
          }
        }
        """;
    
    OrderPlacedEvent event = objectMapper.readValue(validEventJson, OrderPlacedEvent.class);
    
    // When & Then - Sollte ohne Exception verarbeitet werden
    assertDoesNotThrow(() -> paymentService.handleOrderPlaced(event));
}

58.1.4 Schema Evolution Testing

Schemas ändern sich. Contract Tests müssen Backward- und Forward-Compatibility prüfen:

Compatibility Test Matrix:

Schema Version Producer v1 Producer v2 Consumer v1 Consumer v2
v1 Events ✅ Erzeugt ✅ Erzeugt ✅ Versteht ✅ Versteht
v2 Events ❌ Erzeugt nicht ✅ Erzeugt ❓ Test nötig ✅ Versteht

Backward Compatibility Test:

@Test
void newConsumerShouldHandleOldEvents() {
    // Given - Altes Event ohne neues Feld 'priority'
    OrderPlacedEvent oldEvent = OrderPlacedEvent.builder()
        .orderId("order-123")
        .customerId("customer-456")
        .totalAmount(BigDecimal.valueOf(99.99))
        // priority fehlt - Default sollte verwendet werden
        .build();
    
    // When & Then
    assertDoesNotThrow(() -> paymentService.handleOrderPlaced(oldEvent));
    
    // Verifikation dass Default-Priority verwendet wird
    verify(paymentGateway).processPayment(argThat(request -> 
        request.getPriority() == PaymentPriority.NORMAL));
}

58.2 Consumer-driven Contracts

58.2.1 Das Pact-Konzept für Events

Consumer definieren, welche Events sie erwarten. Producer müssen diese Erwartungen erfüllen:

Consumer Contract Definition (Pact-Style):

@ExtendWith(PactConsumerTestExt.class)
class PaymentServiceContractTest {
    
    @Pact(consumer = "PaymentService", provider = "OrderService")
    public MessagePact orderPlacedEventContract(MessagePactBuilder builder) {
        return builder
            .expectsToReceive("OrderPlaced event for standard order")
            .withContent(Map.of(
                "eventType", "OrderPlaced",
                "data", Map.of(
                    "orderId", like("order-123"),
                    "customerId", like("customer-456"),
                    "totalAmount", decimal(99.99),
                    "currency", "EUR"
                )
            ))
            .toPact();
    }
    
    @Test
    @PactTestFor(pactMethod = "orderPlacedEventContract")
    void shouldProcessOrderPlacedEvent(List<Message> messages) {
        // Given
        Message orderMessage = messages.get(0);
        OrderPlacedEvent event = objectMapper.readValue(
            orderMessage.getContents().valueAsString(), 
            OrderPlacedEvent.class
        );
        
        // When & Then
        assertDoesNotThrow(() -> paymentService.handleOrderPlaced(event));
    }
}

58.2.2 Provider Contract Verification

Der Producer (Provider) muss beweisen, dass er die Consumer-Erwartungen erfüllt:

Provider Verification Test:

@ExtendWith(PactVerificationInvocationContextProvider.class)
@Provider("OrderService")
@PactFolder("target/pacts")
class OrderServiceContractTest {
    
    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPacts(PactVerificationContext context) {
        context.verifyInteraction();
    }
    
    @State("standard order exists")
    void standardOrderExists() {
        // Setup für Contract Test
        // Hier würde man Test-Daten vorbereiten
    }
    
    @PactVerifyProvider("OrderPlaced event for standard order")
    String produceOrderPlacedEvent() {
        // Produziere Event wie in echter Anwendung
        CreateOrderRequest request = aValidCreateOrderRequest().build();
        orderService.placeOrder(request);
        
        // Gib das produzierte Event zurück
        ArgumentCaptor<OrderPlacedEvent> captor = ArgumentCaptor.forClass(OrderPlacedEvent.class);
        verify(eventPublisher).publish(eq("order.placed.v1"), captor.capture());
        
        return objectMapper.writeValueAsString(captor.getValue());
    }
}

58.2.3 Consumer-Driven Schema Evolution

Consumer treiben Schema-Änderungen:

Neuer Consumer mit erweiterten Anforderungen:

@Pact(consumer = "InventoryService", provider = "OrderService")
public MessagePact orderPlacedWithItemsContract(MessagePactBuilder builder) {
    return builder
        .expectsToReceive("OrderPlaced event with item details")
        .withContent(Map.of(
            "eventType", "OrderPlaced",
            "data", Map.of(
                "orderId", like("order-123"),
                "customerId", like("customer-456"),
                "totalAmount", decimal(99.99),
                "currency", "EUR",
                // Neue Anforderung vom InventoryService
                "items", eachLike(Map.of(
                    "productId", like("product-123"),
                    "quantity", like(2),
                    "unitPrice", decimal(49.99)
                ))
            )
        ))
        .toPact();
}

Der OrderService muss nun erweitert werden, um diese neue Anforderung zu erfüllen.

58.3 Cross-team Testing Strategies

58.3.1 Organisatorische Herausforderungen

In Microservice-Architekturen arbeiten verschiedene Teams an verschiedenen Services. Contract Testing ermöglicht unabhängige Entwicklung:

Team-Verantwortlichkeiten:

Team Verantwortlichkeit Artefakt
Order Team Producer Contract erfüllen Event-Schema, Provider Tests
Payment Team Consumer Contract definieren Consumer Tests, Pact Files
CI/CD Pipeline Contract Compatibility prüfen Automated Verification

58.3.2 Contract-First Development

Teams entwickeln zunächst Contracts, dann Implementierungen:

1. Consumer definiert Erwartungen:

# payment-service-contracts.yml
contracts:
  - name: "OrderPlaced Event"
    provider: "OrderService"
    consumer: "PaymentService"
    message:
      eventType: "OrderPlaced"
      required_fields:
        - orderId
        - customerId  
        - totalAmount
        - currency
    example:
      orderId: "order-123"
      customerId: "customer-456"
      totalAmount: 99.99
      currency: "EUR"

2. Producer implementiert gegen Contract:

@Test
void shouldSatisfyPaymentServiceContract() {
    // Contract als Test-Input laden
    ContractSpecification contract = ContractLoader.load("payment-service-contracts.yml");
    
    // Event produzieren
    CreateOrderRequest request = requestFromContract(contract);
    orderService.placeOrder(request);
    
    // Verify contract compliance
    ArgumentCaptor<OrderPlacedEvent> captor = ArgumentCaptor.forClass(OrderPlacedEvent.class);
    verify(eventPublisher).publish(any(), captor.capture());
    
    contract.verify(captor.getValue());
}

58.3.3 Pipeline Integration

Contract Tests laufen in verschiedenen Pipeline-Stufen:

Contract Testing Pipeline:

# .github/workflows/contract-tests.yml
name: Contract Tests

on: [push, pull_request]

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Run Consumer Contract Tests
        run: ./gradlew pactTest
      
      - name: Publish Pacts
        run: ./gradlew pactPublish
  
  provider-verification:
    needs: consumer-tests
    runs-on: ubuntu-latest
    steps:
      - name: Verify Provider Contracts  
        run: ./gradlew pactVerify
        
      - name: Can-I-Deploy Check
        run: pact-broker can-i-deploy --pacticipant OrderService --version ${{ github.sha }}

58.3.4 Breaking Change Detection

Contract Tests erkennen Breaking Changes automatisch:

Beispiel Breaking Change:

// Alter Producer
OrderPlacedEvent event = OrderPlacedEvent.builder()
    .orderId("order-123")
    .customerId("customer-456")  
    .totalAmount(BigDecimal.valueOf(99.99))
    .currency("EUR")
    .build();

// Neuer Producer - BREAKING CHANGE
OrderPlacedEvent event = OrderPlacedEvent.builder()
    .orderId("order-123")
    .customerId("customer-456")
    .amount(BigDecimal.valueOf(99.99))  // totalAmount → amount
    .currency("EUR")
    .build();

Contract Test schlägt fehl:

Contract verification failed:
- Expected field 'totalAmount' not found in event
- Consumer PaymentService expects 'totalAmount' but Provider OrderService produces 'amount'

58.3.5 Deployment Coordination

Contract Tests koordinieren Deployments zwischen Teams:

Can-I-Deploy Matrix:

Scenario OrderService v2.1 PaymentService v1.3 Deployment Safe?
Backward Compatible ✅ Erweitert Schema ✅ Toleriert alte Events ✅ Ja
Breaking Change ❌ Ändert Schema ❌ Erwartet altes Schema ❌ Nein
Forward Compatible ✅ Neues Schema ✅ Ignoriert unbekannte Felder ✅ Ja

Contract Testing auf Event-Ebene schafft Vertrauen zwischen Teams und Services. Es ermöglicht unabhängige Entwicklung, während es gleichzeitig Integration-Sicherheit gewährleistet. Der Schlüssel liegt darin, Contracts als First-Class-Citizens zu behandeln und sie in die gesamte Entwicklungs- und Deployment-Pipeline zu integrieren.