Integration von Microservices mit Consumer-driven Contracts testen

Consumer-driven Contracts: So lassen sich Microservices richtig testen
Keine Kommentare

Bei der Entwicklung eines verteilten Systems hat man es naturgemäß mit vielen Schnittstellen zwischen den Systemkomponenten zu tun. Das Testen der Kommunikation zwischen diesen Komponenten kann schnell zur Sisyphusarbeit ausarten, wenn man nach jeder Änderung das Zusammenspiel aller Komponenten erneut prüfen muss. Dieser Artikel beschreibt mit dem Konzept der Consumer-driven Contracts einen Weg, auf beiden Seiten der Schnittstelle effektiv und automatisiert zu testen, um stets den Überblick über die Funktionsfähigkeit der Schnittstellen zu behalten.

Bevor wir in Contract-Tests und dann in Consumer-driven Contract-Tests eintauchen, stellen wir uns die Frage, warum wir sie überhaupt brauchen. Diese Frage lässt sich entlang einer Diskussion der Testpyramide beantworten. Die Testpyramide ist eine Metapher zur Darstellung des Anteils verschiedener Arten von Tests in der Teststrategie eines Softwareprojekts. Der Begriff wurde ursprünglich von Mike Cohn geprägt [1]. Die Basis der Teststrategie sind einfache, schnelle Unit-Tests ohne Abhängigkeiten zu anderen als der getesteten Einheit. Komplexe und langsame End-to-End-Tests bilden die Spitze der Pyramide und sind damit weniger zahlreich.

Abb. 1: Testpyramide

Die Testpyramide ist in mehrere Ebenen unterteilt, die von unten nach oben immer schmaler werden (Abb. 1). Auf der untersten Ebene sind üblicherweise Unit-Tests zu finden. Wie der Name schon andeutet, sind Unit-Tests automatisierte Tests von isolierten Einheiten des Quellcodes. Diese Tests haben keine Abhängigkeiten zu anderen Einheiten. Eine „Unit“ kann eine Methode, eine Klasse oder ein Set von Klassen sein, es gibt hier keine einheitliche Definition.

Unit-Tests haben eine kurze Laufzeit und können ohne komplexe Infrastruktur einfach automatisiert ausgeführt werden. Deshalb sollten sie die Basis der Teststrategie bilden und einen entsprechend hohen Anteil des Quellcodes abdecken. Auf der nächsten Ebene kommen Integrationstests ins Spiel. Diese Tests validieren, dass das Zusammenspiel verschiedener Einheiten funktioniert wie erwartet. Integrationstests können zum Beispiel prüfen, ob ein eingehender HTTP-Request von unserer Webanwendung korrekt verarbeitet wird, und damit das Zusammenspiel aller zur Beantwortung des Requests erforderlichen Komponenten validieren. Integrationstests benötigen ein aufwendigeres Set-up als Unit-Tests, da hier mehrere Komponenten in einen zum Test passenden Zustand gebracht werden müssen. Wird zum Beispiel der Zugriff auf eine Datenbank mitgetestet, muss diese zuvor mit entsprechenden Testdaten bespielt werden. Üblicherweise haben Integrationstests deshalb auch eine längere Laufzeit als Unit-Tests. Darüber hinaus sind sie anfälliger für unvorhergesehene Testfehlschläge, da sie durch Codeänderungen in mehr als nur einer Komponente beeinflusst werden.

Es sei bemerkt, dass die Grenze zwischen isolierten Unit-Tests und Integrationstests fließend ist. So kann ein Test auch „isoliert“ genannt werden, wenn mehrere Komponenten gleichzeitig getestet werden. Spätestens aber, wenn das System in den Tests über eine klar definierte Schnittstelle (wie zum Beispiel eine REST-Schnittstelle) angesprochen wird, sollte man von Integrationstests sprechen. In der für diesen Artikel genutzten Definition haben Unit-Tests und Integrationstests aber gemeinsam, dass sie auch ausführbar sind, wenn kein „echtes“ Testsystem verfügbar ist. Das heißt, sie können im Rahmen des normalen Build-Prozesses im Hauptspeicher ausgeführt werden. Für einen Integrationstest wird unsere Anwendung also in einem eigenen Prozess lokal gestartet, um sie dann zum Beispiel mit (lokalen) HTTP-Requests zu testen.

Diese Eigenschaft geht auf der dritten Ebene der Testpyramide verloren. Für End-to-End-(E2E-)Tests wird eine echte Anwendung auf einem voll funktionsfähigen Testsystem vorausgesetzt. In diesen Tests wird die Benutzeroberfläche angesteuert und es wird geprüft, ob die Anwendung sich wie erwartet verhält. Damit wird durch End-to-End-Tests ein echter Benutzer besser simuliert als durch die anderen Testarten.

Mit End-to-End-Tests in die Integrationshölle

Auf den ersten Blick scheinen End-to-End-Tests der natürliche Weg, um ein verteiltes System zu testen. Man startet eine Testumgebung, in der alle Services laufen, und lässt ein Set von automatisierten Tests auf die Benutzeroberfläche los. Die Tests laufen gegen das echte System, prüfen also auch direkt alle Schnittstellen ab, die unterwegs genutzt werden (Abb. 2).

Abb. 2: Bei End-to-End-Tests wird die Anwendung über die Benutzeroberfläche getestet

Allerdings erkauft man sich die Vorteile von End-to-End-Tests mit einem hohen Preis. So ist zum Beispiel das Set-up von End-to-End-Tests deutlich komplexer als das von Integrations- oder Unit-Tests, da ein echtes System erforderlich ist, in dem jeder Service mit der zugehörigen Datenbank läuft. Wenn man End-to-End-Tests automatisiert aus einer Continuous Integration Pipeline heraus starten möchte, muss das Set-up dieses Testsystems automatisiert werden, was keine leichte Aufgabe ist.

Das allein ist aber kein Argument gegen End-to-End-Tests. Da bei der Entwicklung einer verteilten Architektur ohnehin ein hoher Automatisierungsgrad erforderlich ist, kann man das Hochfahren dieses Testsystems einfach mit automatisieren. Gehen wir also davon aus, dass ein komplettes Testsystem mit allen Services und deren Datenbanken (initialisiert mit einer zueinander passenden Menge von Testdaten!) automatisiert aus einer Continuous Integration Pipeline heraus hochgefahren werden kann. Der Anspruch von Continuous Integration (CI) ist, dass Änderungen am Code zeitnah mit automatisierten Tests geprüft werden und man schnell Feedback bekommt, ob die Änderungen zu Fehlern geführt haben oder nicht. Dadurch, dass für End-to-End-Tests zunächst eine komplette Testumgebung hochgefahren werden muss und sie naturgemäß einfach eine längere Ausführungszeit haben, kommt dieses Feedback aber meist viel zu spät. Zusätzlich stellt sich die Frage, ob die Tests überhaupt bei jeder Codeänderung ausgeführt werden können. Arbeitet man zum Beispiel mit dem beliebten Feature-Branch/Pull-Request-Modell (Abb. 3), müsste man für jede Änderung in jedem Feature-Branch in jedem Service eine eigene Testumgebung hochfahren, damit sie sich nicht gegenseitig in die Quere kommen. Für jeden Service in einer verteilten Architektur gibt es üblicherweise eine eigene Codebase, in der Features z. B. nach dem dargestellten Konzept in den produktiven Master-Branch gelangen. Für jeden Commit auf dem Weg dorthin (dargestellt durch einen Punkt), sollen alle automatisierten Tests ausgeführt werden.

Selbst mit Containertechnologien hat man hier schnell eine Ressourcengrenze erreicht (oder mit Cloud-Anbietern eine Kostengrenze).

Abb. 3: Das Feature-Branch/Pull-Request-Modell

Selbst wenn man auf den Vorteil des schnellen Feedbacks verzichtet und die End-to-End-Tests nur ein paarmal am Tag ausführt, muss man damit rechnen, dass das Debugging von fehlschlagenden Tests sehr viel Zeit in Anspruch nimmt. Der Grund: Unter allen Komponenten, die in der Testumgebung hochgefahren wurden, muss erst einmal die für den Fehlschlag verantwortliche gefunden werden. Das führt schnell zu gefährlichen Gewohnheiten wie: „Es reicht aus, wenn 90 Prozent der Tests erfolgreich durchlaufen.“ Halten wir also fest, dass End-to-End Tests zumindest für das explizite Testen von Schnittstellen keine ideale Lösung sind.

Contract-Tests

Um die Schnittstellen zwischen den GUIs und Services isolierter und damit auch schneller und stabiler testen zu können, müssen wir in der Testpyramide eine Stufe nach unten treten und Integrationstests entwickeln. Die Integrationstests für eine Schnittstelle sollen sowohl in der CI-Pipeline des Schnittstellenanbieters (Provider) als auch in der Pipeline des Schnittstellenkonsumenten (Consumer) laufen, um gezielte Aussagen darüber zu ermöglichen, auf welcher Seite der Schnittstelle ein eventueller Fehler zu suchen ist. Darüber hinaus sollen die Tests unabhängig von dem jeweils anderen Schnittstellenpartner lauffähig sein.

Abb. 4: Contract-Test

Contract-Tests erfüllen diese Anforderungen (Abb. 4). Ein Vertrag beinhaltet Beispiel-Requests und Responses, die als Input für einen Mock Provider und einen Mock Consumer dienen, gegen die die Schnittstellenpartner unabhängig voneinander getestet werden können. Hierbei wird der implizit ohnehin vorhandene Vertrag zwischen den Schnittstellenpartnern explizit definiert und dient als Basis für den Schnittstellentest. Der Vertrag beschreibt die Requests, die vom Consumer ausgehen und die dazugehörigen Responses des Providers in einer maschinenlesbaren Form. Anstatt die Schnittstelle über einen End-to-End-Test abzutesten, der sowohl den Provider als auch den Consumer benötigt, werden nun zwei Tests durchgeführt: einer auf jeder Seite der Schnittstelle ohne direkte Abhängigkeit zur jeweils anderen Seite.

Der Consumer-Test prüft, ob die vom Consumer gesendeten Requests konform zum Schnittstellenvertrag sind. Hierfür schickt er seine Requests gegen einen Provider-Mock, der sie mit den im Vertrag definierten Requests vergleicht und den dazu passenden Response aus dem Vertrag zurückliefert. Wird kein passender Request im Vertrag gefunden, schlägt der Test fehl, denn dann hat der Consumer offenbar einen ungültigen Request gesendet. Wurde ein Request aus dem Vertrag nicht gesendet, schlägt der Test ebenfalls fehl, um darauf aufmerksam zu machen, dass die Implementierung nicht den kompletten Vertrag abdeckt. Auf der anderen Seite der Schnittstelle prüft der Provider-Test, ob die vom Provider gelieferten Responses dem Vertrag entsprechen. Hierfür wird er von einem Consumer Mock mit allen Requests aus dem Vertrag befeuert. Der Mock prüft dann, ob die Response vom Provider den im Vertrag definierten Erwartungen entspricht.

Die Integration in eine Continuous Integration Pipeline ist dann denkbar einfach: Im Consumer Build wird der Consumer-Test ausgeführt, und im Provider-Builder wird der Provider-Test ausgeführt. Schlägt einer der Tests fehl, wissen wir sofort, ob wir im Consumer oder im Provider nach der Ursache suchen müssen. Die Mocks werden im Rahmen der Tests lokal gestartet, sodass wir keinerlei Abhängigkeiten mehr zu einem kompletten Testsystem haben.

Es gibt nur eine Wahrheit

So weit, so gut. Wir definieren einen Vertrag mit Beispiel-Requests und -Responses für jede Schnittstelle und implementieren CI-freundliche, voneinander unabhängige Contract-Tests für jeden Consumer und jeden Provider. Wo aber liegt der Vertrag, der ja Input für diese Tests ist?

Der naive Ansatz ist es, den Vertrag sowohl in der Codebase des Consumers als auch in der Codebase des Providers abzulegen, denn an beiden Stellen wird er ja schließlich auch für die Ausführung der Tests benötigt. Es ist leicht nachvollziehbar, dass das keine gute Idee ist, denn es gibt dann zwei Kopien desselben Vertrags, die bei einer Änderung der Schnittstelle angepasst werden müssen. Wird der Vertrag aus Versehen zum Beispiel nur auf der Consumer-Seite geändert, laufen alle Tests auf der Provider-Seite mit der alten Version des Vertrags immer noch erfolgreich durch, obwohl die Schnittstelle im produktiven Betrieb in einen Fehler laufen wird, weil Consumer und Provider nicht mehr dieselbe Sprache sprechen. Eine gefährliche Situation, denn die Tests vermitteln eine falsche Sicherheit.

Kostenlos: Das iJS React Cheat Sheet

Sie wollen mit React durchstarten?
Unser Cheatsheet zeigt Ihnen alle wichtigen Snippets auf einen Blick.
Jetzt kostenlos herunterladen!

Download for free

 

API Summit 2018

From Bad to Good – OpenID Connect/OAuth

mit Daniel Wagner (VERBUND) und Anton Kalcik (business.software.engineering)

Die Lösung dafür ist, den Vertrag an einer zentralen Stelle abzulegen. Das kann ein Git-Repository sein (ggf. auch das Repository des Consumers oder Providers), ein gemeinsames Netzlaufwerk oder ein explizit dafür entwickelter Contract-Server wie der Pact Broker. Wird der Vertrag im Consumer- und im Provider-Test jeweils von dieser zentralen Stelle geladen, schlagen Änderungen am Vertrag allerdings direkt auf die Build-Pipelines beider Schnittstellenpartner durch. Beide Builds werden dann erst einmal fehlschlagen. Solange Consumer und Provider nicht an den geänderten Vertrag angepasst sind, behindern sie damit die weitere Arbeit an anderen Features. Deshalb ist es ratsam, alle Anpassungen zeitnah zueinander durchzuführen oder mit einer geschickten Versionierung dafür zu sorgen, dass die neue Version des Vertrags erst ab einem bestimmten Zeitpunkt im Build berücksichtigt wird. Irgendwann muss dann aber sichergestellt werden, dass beide Schnittstellenpartner dieselbe Version des Vertrags nutzen, ansonsten haben wir wieder das Problem der falschen Sicherheit. Alternativ können wir den Build auch so konfigurieren, dass er nicht mehr fehlschlägt, wenn ein Contract-Test fehlschlägt, sondern nur noch eine Warnung ausgibt. Aber dann ist Disziplin gefragt, denn jemand muss sich dann auch darum kümmern, dass die Implementierung an die neue Version des Vertrags angepasst wird.

Consumer-driven

Nun stellt sich noch die Frage, wer für den Schnittstellenvertrag verantwortlich ist. Wer erstellt ihn? Wer pflegt ihn? Das konventionelle Vorgehen ist, dass derjenige, der eine Schnittstelle anbietet, auch den Vertrag definiert. Somit liegt die Verantwortung beim Provider. Beleuchten wir dieses Vorgehen anhand eines Beispiels.

Nach Abstimmung mit dem Consumer-Team erstellt das Provider-Team einen Vertrag, in dem drei REST Endpoints definiert sind. Provider und Consumer werden gemäß dem Vertrag entwickelt, und alles funktioniert wie erwartet. Nun ändern sich die Anforderungen, und der Consumer benötigt nur noch zwei dieser Endpoints. Der Consumer-Code und der dazugehörige Contract-Test werden angepasst. Allerdings wird das Provider-Team nicht darüber in Kenntnis gesetzt, dass der Endpoint nicht mehr benötigt wird. Schließlich gehört der Vertrag dem Provider-Team, und das sollte wissen, ob der Endpoint ggf. noch von anderen Consumern benötigt wird. Von diesem Zeitpunkt an schleppt das Provider-Team Code für einen unnötigen REST-Endpoint und den zugehörigen Contract-Test mit sich herum, die eigentlich gar nicht mehr benötigt werden. Da der Provider Herr über den Vertrag ist, kann es darüber hinaus vorkommen, dass Schnittstellen anders aussehen, als es für den Consumer optimal wäre. In einem ähnlichen Szenario wie oben könnte es deshalb passieren, dass ein Consumer einen vom Provider angebotenen Endpoint nutzt, der eigentlich für einen anderen Zweck gedacht war. So kommt es dazu, dass der Consumer Workarounds implementieren muss, um mit einem für ihn suboptimalen Endpoint zurechtzukommen.

Die beiden beschriebenen Fälle lassen sich verhindern, indem der Consumer die Kontrolle über den Vertrag erhält. Dann kann er genau diejenigen Endpoints definieren, die er braucht, und genau in der Form, in der er sie braucht. Wird ein Endpoint nicht mehr benötigt, kann der Consumer ihn einfach aus dem Vertrag löschen und den Vertrag neu veröffentlichen. Das führt dazu, dass auf Provider-Seite der Contract-Test des gelöschten Endpoints fehlschlägt, da in dem Test der Aufruf eines Endpoints erwartet wird, der nicht mehr getätigt wird. Der Provider kann den zugehörigen Code dann getrost entfernen.

Bei der Implementierung der Consumer-driven Contract-Tests ist darauf zu achten, dass für jedes Consumer-Provider-Paar eine eigenständige Testsuite existiert. Wird ein Provider-Endpoint von zwei Consumern benötigt, muss es dafür zwei Tests auf Consumer-Seite und zwei auf Provider-Seite geben (jeweils einen pro Consumer). Schlägt einer dieser Tests fehl, wissen wir sofort, wo die Ursache zu suchen ist. Auch weiß der Provider dann immer, welche Endpoints aktuell noch in Benutzung sind. Gibt es keinen Test für einen Endpoint, kann er entfernt werden, da es dann auch keinen Consumer gibt, der ihn benötigt. Wird dagegen ein Endpoint gelöscht, der aber von einem Consumer benötigt wird, macht ein fehlschlagender Test uns darauf aufmerksam.

Naturgemäß können Consumer-driven Contract-Tests nur genutzt werden, wenn die Consumer bekannt sind. Für Public APIs ist das Konzept ungeeignet, denn hier muss die Kontrolle über den Vertrag zwangsläufig beim Provider liegen. Stattdessen müssen wir hier mit abwärtskompatiblen Änderungen oder einer API-Versionierung arbeiten.

Frameworks

Contract-Tests im Allgemeinen und Consumer-driven Contract-Tests im Speziellen müssen wir nicht von Grund auf selbst entwickeln. Es gibt eine Reihe von Frameworks, die uns dabei unterstützen. In der Java-Welt können zum Beispiel WireMock zur Entwicklung von Mock-Providern und REST Assured zur Entwicklung von Mock-Consumern genutzt werden. Mit diesen Frameworks hat man bereits alles, was man braucht, um Contract-Tests zu implementieren. Allerdings muss man mit diesen Low-Level-Frameworks noch einiges dazubauen, um Consumer-driven Contract-Tests auch richtig zu unterstützen. So muss zum Beispiel das Format definiert werden, in dem die Verträge spezifiziert werden, und es muss mögich sein, auf die Verträge zentral zuzugreifen.

Einen höheren Abstraktionsgrad bieten Frameworks, die explizit das Konzept der Consumer-driven Contracts unterstützen. Pact ist so ein Framework beziehungsweise eine Sammlung von Frameworks, denn es unterstützt viele Programmiersprachen. Durch den polyglotten Ansatz ist es auch möglich, Consumer-driven Contract-Tests zwischen Services zu implementieren, die auf unterschiedlichen Technologiestacks basieren. Mit dem Pact Broker bietet Pact auch einen eigenen Contract-Repository-Server zur zentralen Verwaltung der Verträge.

Spring Cloud Contract ist ein weiteres Consumer-driven-Contract-Framework, das zunächst nur JVM-Sprachen unterstützt. Durch Anbindung eines Pact Brokers werden allerdings indirekt auch andere Technologiestacks unterstützt. Während Pact die Mock Provider und Mock Consumer zur Laufzeit aus den Verträgen erzeugt, verfolgt Spring Cloud Contract einen anderen Ansatz. Statt eines Mock-Providers wird aus dem Vertrag ein eigenes, versioniertes Maven-Artefakt erzeugt, das dann im Rahmen der Consumer-Tests als Provider Stub genutzt werden kann. Und an Stelle eines Mock Consumers werden im Build-Prozess automatisiert Tests generiert, die den Provider testen.

Fazit

Schnittstellen zu entwickeln und zu testen ist schwierig und erfordert viel gute Kommunikation. Mit Contract-Tests ist es möglich, die technischen Hürden für die Schnittstellentests zu reduzieren, sodass sie regelmäßig ausgeführt werden können und aussagekräftig sind. Consumer-driven-Contract-Tests verbessern zusätzlich noch die Kommunikation zwischen Consumer und Provider. Fehlschlagende Tests machen uns darauf aufmerksam, wenn sich eine Anforderung geändert hat und moderieren so zwischen den Entwicklern des Providers und des Consumers. Kommunikation ist und bleibt das wichtigste Element für erfolgreiche Schnittstellenentwicklung. Auch wenn es „Consumer-driven“ heißt, sollten Vertragsänderungen gemeinsam mit dem Provider abgestimmt werden.

Links & Literatur
[1] Cohn, Mike: „Succeeding with Agile“, Addison-Wesley, 2009

Entwickler Magazin

Entwickler Magazin abonnierenDieser Artikel ist im Entwickler Magazin erschienen.

Natürlich können Sie das Entwickler Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -