Alexander Henze

Estimated reading time: 8 Minuten

Techblog

Consumer Contract Testing

Pacta sunt servanda Wenn Vertragsrecht auf Software-Engineering trifft Nein, keine Angst, es folgt keine Abhandlung aus dem juristischen Proseminar. Mit dem Framework Pact lassen sich automatisiert Schnittstellen auf ihre Vertragstreue testen.  Aber warum braucht man das überhaupt? Um diese Frage zu beantworten, hilft ein Blick in die Geschichte der Software-Architektur.  Bis zum Aufkommen der Microservice-Architekturen wurde Software klassischerweise als Monolith entworfen. Dabei…

Techblog

Pacta sunt servanda

Wenn Vertragsrecht auf Software-Engineering trifft

Nein, keine Angst, es folgt keine Abhandlung aus dem juristischen Proseminar. Mit dem Framework Pact lassen sich automatisiert Schnittstellen auf ihre Vertragstreue testen. 

Aber warum braucht man das überhaupt? Um diese Frage zu beantworten, hilft ein Blick in die Geschichte der Software-Architektur. 

Bis zum Aufkommen der Microservice-Architekturen wurde Software klassischerweise als Monolith entworfen. Dabei gab es keinerlei interne Schnittstellen, da alle Informationen im Monolithen weitergereicht werden konnten. Dementsprechend konnte man sich im Testing nur auf die Stufen Unittests, Integrationstests und End-to-End-Tests (Abbildung 1) konzentrieren.

Abbildung 1: Klassische Testpyramide in monolithischen Systemen 

In Microservice-Architekturen existiert nun eine Vielzahl an internen Schnittstellen, und damit entsteht die Notwendigkeit, diese zu testen. Die Kommunikation der Services untereinander geschieht dabei natürlich nicht willkürlich, sondern folgt Regeln, den sogenannten Schnittstellen-Contracts. Abbildung 2 verdeutlicht dies exemplarisch: 

Abbildung 2: Contract zwischen Consumer und Provider 

Service B stellt unter dem Pfad /productDetails/{id} eine REST-Schnittstelle zur Verfügung, die Service A aufruft. Service A erwartet von Service B, dass dieser mit einem Status Code 200 (OK) sowie einem Json als Body antwortet, das aus den 3 Feldern id (eine Nummer), name (ein String), und price (ein Double) besteht. Diese Erwartungshaltung wird als Contract bezeichnet, sie zu testen ergo als Contract Testing. Typischerweise wird der Contract durch den Aufrufer definiert, weswegen man auch von Consumer Contract Testing spricht. 

Wenn man in Microservice-Architekturen dieselben Testebenen hernähme wie in monolithischen Anwendungen, würde man die Kommunikation der Services untereinander erst auf der End-to-End Stufe implizit mittesten. Wenn man sich vorstellt, dass man ein System von 40 miteinander kommunizierenden Microservices hat, kann man schnell erahnen, dass die Identifikation einer Fehlerursache in einem solchen System extrem mühsam wäre. Daher kommt bei Microservice Architekturen 1 zusätzliche Testebene hinzu, nämlich ebenjene Consumer-Contract-Tests. Diese werden nach den funktionalen Unit- und Integrationstests und vor den End-2-End-Tests ausgeführt (Abbildung 3).

Abbildung 3: Testpyramide in Microservice-Architekturen

Ein Framework für diese Art von Tests ist Pact (mehr dazu unter https://docs.pact.io

Pact

Das Einbinden von Contract Tests erzeugt erfreulicherweise fast keinen Mehraufwand im Vergleich zum Testen in klassischen Monolithen. Folgender Ausschnitt zeigt einen exemplarischen Test 

Abbildung 4: Beispiel Test für Microservice-Applikationstest

Man benötigt so oder so einen Integrationstest, der über die Rest-Schnittstelle den Service aufruft, die Verarbeitung triggert und dann das gelieferte Ergebnis evaluiert. Da aber während der Berechnung ein anderer Microservice gecalled werden muss, würde man in jedem Fall einen Mock benötigen, um den Endpunkt einmal im Komplettdurchstich zu testen. Statt nun einfach einen Wiremock aufzusetzen und zu konfigurieren, bringt Pact eine DSL mit, die prinzipiell genau dasselbe tut.

Mithilfe der Annotation PactTestFor lässt sich genau konfigurieren, wie der Mock heißt und unter welchem Port er lokal ansprechbar ist. Das genaue Verhalten, wie er bei einem Aufruf reagieren soll, wird in einer Methode gekapselt. In unserem Beispiel wird spezifiziert, dass bei einem Aufruf des Endpunktes /productDetails/1 mit einem Code 200 OK geantwortet werden soll und einem Json, das aus den drei Feldern id (Wert: 1), name („Awesome product no 1“) und price (4.99).

Zusätzlich gibt man eine fachliche Einordnung, warum diese externe Schnittstelle aufgerufen wird, und beschreibt, in welchem Zustand der Provider sein muss, damit dieser so antwortet wie hier gewünscht. Das bedeutet, wenn der Consumer z.B. die Produktdetails für Produkt Nummer 1 anfragt, muss dieser Datensatz natürlich beim Provider in der Datenbank verfügbar sein. Danach kann der Test normal ausgeführt werden.

Pact wird nach Abschluss des Tests ein json (den sogenannten Pact) generieren, das exakt die Spezifikation enthält, wie sie im Test definiert wurde (Abbildung 5). Gegen dieses json wird dann der Provider validiert.

Abbildung 5: Generierter Pact in json Format

Validierung

Man hat zwei Möglichkeiten, dem Provider dieses json zur Verfügung zu stellen, damit er sich dagegen validieren kann. Man könnte es einfach in das Repository des Providers kopieren.  Alternativ hält man eine zentrale Instanz vor, die die Verwaltung und Verteilung dieser jsons übernimmt. Für letzteres gibt es bei Pact den sogenannten Pact Broker. Diesem kann man das json mittels Rest Call publizieren und dort dann auch alle Contracts an einer zentralen Stelle einsehen und verwalten. Als besonderes Bonbon generiert Pact Broker eine Übersicht über die komplette Systemkommunikation aus den vorhanden Pacts (Abbildung 6). Für das Veröffentlichen des generierten Pacts an den Broker gibt es u.a. ein Gradle-Plugin, dem man nur den Pfad zum json und die Lokation des Broker angeben muss; es übernimmt dann die Veröffentlichung.

Abbildung 6: Generierter Kommunikationsgraph im Pact Broker

Providertest

Möchte man nun auf Seiten des Providers prüfen, dass die Implementierung diesem Pact genüge tut, so kann man die generierten Pacts aus dem Consumer als Input für Tests nutzen. Verwendet man im Projekt zum Beispiel Spring, so ist das Setup der Tests recht schnell erledigt (Abbildung 7). 

Abbildung 7: Providertest

Über der Testklasse setzt man folgende Annotationen. RunWith bezeichnet dabei den TestRunner. Provider identifiziert, welcher Provider hier gerade getestet werden soll; dies wird benötigt, um nur die Pacts abzurufen, die auch für diesen Provider gültig sind. Zuletzt bezeichnet PactBroker dann die Adresse, wo der Pact Broker zum Abruf der Pacts zur Verfügung steht. 

Wie bereits weiter oben erwähnt, muss der zu testende Microservice auch in einem gültigen Zustand sein, damit die Tests erfolgreich durchlaufen können. Wenn wir also einen Zustand hätten, der einen bestimmten Datensatz in der Datenbank erwartet, so müssen wir diesen Datensatz zunächst vor Testausführung in die Datenbank schreiben.

Dazu schreiben wir die entsprechende Methode, annotieren sie mit State und geben den entsprechenden Zustand so an, wie er im Consumer benannt wurde. Den Rest übernimmt das Framework für uns. Wenn wir nun die Tests ausführen, erkennt Pact, welcher State für welchen Test hergestellt werden muss, und führt für alle Pacts je einen Test aus.

Fazit: Praktisch!

Zusammenfassen lässt sich sagen, dass man mit dem Pact Framework Micorservice-Schnittstellen ziemlich praktisch testen kann, ohne einen großen Mehraufwand zu erzeugen. Zusätzlich bietet der Pact Broker eine große Unterstützung bei der Systemdokumentation. Damit verbindet Pact das nützliche (Tests) mit dem praktischen (Dokumentation).  

Für Interessierte liegt der hier verwendete Quellcode in unserem gitlab unter https://github.com/reAlHe/pact-spring-kotlin-example.

In diesem Sinne: Happy Testing!


Über den Autor

Alexander Henze