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?
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"]}
}
}
}
}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)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));
}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));
}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));
}
}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());
}
}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.
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 |
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());
}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 }}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'
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.