5. Oktober 2017 von Tom Hombergs
Integration von Microservices mit Consumer-driven Contracts testen
Die Integrationshölle
Es kommt vor, dass die Kommunikation mit einem Fremdsystem oder einem eigenen Subsystem einfach nicht mehr funktioniert. Da es immer mehr Anwendungsfälle gibt, die ohne eine Kommunikation über Systemgrenzen hinweg nicht mehr auskommen, müssen die Entwickler Überstunden schieben, um die Schnittstelle wieder zum Laufen zu bringen.
Integrationstests sind ein häufig eingesetztes - aber nur bedingt erfolgreiches - Mittel, um das Risiko nicht-funktionierender Schnittstellen zu verringern. Dabei stellt jeder Schnittstellenpartner ein Testsystem bereit, das dann für (hoffentlich automatisierte) Tests genutzt werden kann. Leider sind solche Systeme nicht einfach zu handhaben. Sie erfordern Wartungsarbeiten analog zu denen eines Produktionssystems. Oft fallen diese Wartungsarbeiten „wichtigeren“ Aufgaben zum Opfer, was dazu führt, dass die Integrationstests nicht stabil laufen und dadurch an Aussagekraft verlieren. Darüber hinaus setzt jeder Integrationstest einen gewissen Datenbestand voraus, sodass ein ausgefeiltes und komplexes Konzept erforderlich ist, nach dem die Systeme mit Daten bespielt werden.
Will man Continuous Delivery praktizieren, stellen Integrationstests sogar ein handfestes Hindernis dar. Denn dann setzt man voraus, dass nach der Umsetzung jedes neuen Features automatisierte Tests ausgeführt werden. Erst wenn diese erfolgreich sind, ist eine neue Version der Software für eine potentielle Auslieferung bereit. Für jedes Feature müsste also automatisiert ein isoliertes Testsystem mit dem richtigen Datenbestand für die ebenfalls automatisierten Integrationstests zur Verfügung stehen. Das ist selbst mit Container-Management-Systemen wie Docker schwer zu handhaben.
Denken wir jetzt noch an eine Microservice-Umgebung, die durch eine große Anzahl kleiner, miteinander kommunizierender Services definiert ist, hat man die Hölle erreicht. Hier angekommen, muss entweder sehr viel Aufwand in Integrationstests gesteckt werden oder man verzichtet komplett auf sie.
Consumer-driven Contracts
Sogenannte Contract Tests sind eine Möglichkeit, um aus der Integrationshölle zu entkommen. Anstatt dass jeder Nutzer (Consumer) gegen seine Anbieter (Provider) in einer Integrationsumgebung testet (mit allen oben aufgezählten Nachteilen), testen Consumer und Provider einfach gegen einen Schnittstellenvertrag.
Jede Schnittstelle stellt implizit bereits einen Vertrag zwischen Provider und Consumer dar. Wird dieser gebrochen, indem der Anbieter zum Beispiel eine nicht rückwärtskompatible Änderung durchführt, funktioniert die Kommunikation nicht mehr.
Werden diese Verträge explizit als Dokument zur Verfügung gestellt, können sie vom Consumer und Provider genutzt werden, um ihre jeweilige Seite der Schnittstellen zu validieren (mehr dazu im Abschnitt „Contract Tests“).
Warum sollten diese Verträge nun aber „Consumer-driven“ sein, also durch die Nutzer und nicht durch die Anbieter getrieben? Der Hintergrund ist einfach: Ein Provider soll nur die Schnittstellen anbieten, die auch von mindestens einem Consumer benötigt werden. Durch die Consumer Contracts definieren die Consumer, welche sie benötigen und damit auch, welche vom Provider angeboten werden müssen. Dabei werden diese in Rücksprache mit dem Provider und anderen Consumern definiert. So können z.B. ähnliche Schnittstellen vereinheitlicht werden. Ein Consumer kann nach einer Schnittstellenänderung die Tests gegen seine Consumer Contracts ausführen und weiß aufgrund ggf. fehlschlagender Tests sofort, ob und auf welche Consumer die Änderungen Auswirkungen haben.
Die folgende Abbildung veranschaulicht die Verantwortlichkeiten. Ein Consumer verwaltet und pflegt den Contract in Abstimmung mit dem Provider und anderen Consumern. Beide validieren dann in Contract Tests ihre Seite der Schnittstelle.
Contract Tests
Wie nutzt man einen solchen Contract nun, um die Schnittstelle zu testen?
Die einfachste Möglichkeit ist es, den Contract aus Beispiel-Requests und den jeweils dazu erwarteten Responses aufzubauen. Im Beispiel von HTTP-Schnittstellen stehen in den Requests dann z.B. der aufzurufende Pfad, die Request-Header und der mitgesendete Payload in Form eines JSON-Objekts. In den Beispiel-Responses stehen dann der HTTP-Status, die Response-Header und der Response-Payload als JSON.
Um einen Consumer gegen den Contract zu testen, schickt er einige Requests gegen einen Mock-Provider, der prüft, ob die Requests Teil des Contracts sind. Sind sie es nicht, oder besitzen sie zum Beispiel einen erforderlichen Request-Header nicht, bricht der Mock-Provider mit einem Fehler ab. Passen die Requests, wird die jeweils korrespondierende Response aus dem Contract als Antwort gegeben.
Testet man andersherum einen Provider gegen den Contract, schickt ein Mock-Consumer die Requests aus dem Contract gegen den Provider. Der Mock-Consumer prüft dann, ob die Responses dem Schema aus dem Contract entsprechen und bricht ab, falls dies nicht der Fall ist.
Die folgende Abbildung zeigt alle Beteiligten eines Contract Tests und deren Kommunikation auf.
Grenzen von Contract Tests
Mit einem Contract Test muss man nur noch gegen Mock-Provider und -Consumer testen, die einfach eine gewisse Menge von gültigen Request/Response-Paaren abspielen und prüfen können. Man testet nicht mehr gegen die echten Schnittstellenpartner. Solche Mock-Consumer und -Provider können innerhalb von automatisierten Tests von Frameworks wie z.B. Pact zur Verfügung gestellt werden. Man hat die Integrationshölle hinter sich gelassen.
Allerdings muss klar sein, dass erfolgreiche Contract Tests nur dabei helfen, sicherzustellen, dass Consumer und Provider syntaktisch die gleiche Sprache sprechen. Es wird nicht sichergestellt, dass die fachlichen Prozesse, die mithilfe der Schnittstellen umgesetzt wurden, von Anfang bis Ende wie erwartet funktionieren. Anders gesagt: Ein Consumer wird den Response eines Providers verstehen - ob der Inhalt des Responses aber korrekt ist, muss anderweitig (z.B. mit End-to-End-Tests) überprüft werden.
Fazit
Consumer-driven Contracts sind ein gutes Mittel, um aufwändige Integrationstestumgebungen zu minimieren und somit ein Stück näher in Richtung Continuous Delivery zu kommen. Mit Frameworks wie Pact bekommt man Unterstützung und muss das Test-Framework nicht selbst erstellen. Insbesondere bei der Entwicklung von Systemen mit vielen Schnittstellen, wie z.B. in einer Microservice-Architektur, sollte man über den Einsatz von Consumer-driven Contracts nachdenken.