Microservices sind heute nicht mehr wegzudenken. Doch Abhängigkeiten der Schnittstellen zwischen den Services verhindern oftmals eine unabhängige Weiterentwicklung der Services. Contract-Testing hilft dieses Problem zu lösen.

 

Software wird immer öfter nicht nur als Werkzeug gesehen, sondern als zentraler Baustein der Produktstrategie. Mit der steigenden Bedeutung der Software, muss diese schnell weiterentwickelt werden können, um neue Features mit wenig Verzögerung zum Kunden zu bringen. Eine häufig angewandte Methode, um Software schnell und parallel entwickeln zu können, ist die Zerteilung der Software in kleine, einfach überschaubare und leichtgewichtige Microservices. Obwohl diese Microservices unabhängig voneinander entwickelt und released werden können, bleiben viele Features auf ein Zusammenspiel mehrerer Services angewiesen. An ihren Schnittstellen bleiben Services voneinander abhängig. Oftmals praktizierte Lösungen sind Absprachen und eng abgestimmte, koordinierte Änderungen der API. Diese widersprechen aber dem Wunsch nach unabhängigen Entwicklungs- und Releasezyklen. Unabhängig davon sind manuelle Prozesse immer fehleranfällig und stehen automatisierten Praktiken wie Continuous-Delivery und -Deployments diametral entgegen. Integrative Tests im gesamten Verbund aller Microservices sind teuer und aufwändig und bremsen die unabhängige Entwicklung der einzelnen Services zusätzlich aus.

 

API Typen

Entwickeln wir verteilte Software, so stoßen wir regelmäßig auf APIs, um andere Services zu konsumieren, oder von diesen konsumiert zu werden. Generell gibt es dabei verschiedene Level der Abhängigkeit.

Interne APIs sind zumeist am einfachsten zu handhaben. Hierbei sind Consumer und Provider, also Nutzer und Anbieter der API identisch. Solche APIs kommen häufig vor, wenn ein Team seine Komponenten der Software weiter zerteilt und auf mehrere Services aufgeteilt hat. Auch wenn hier Kommunikation keinen Overhead verursacht und koordinierte Releases verhältnismäßig unkompliziert möglich sind, bleibt die Komplexität der Abhängigkeiten, die API Änderungen schwierig machen.

Schwieriger gestaltet sich die Kommunikation schon, wenn der Consumer oder Provider der API nicht mehr das gleiche Team ist, sondern ein anderes Team im Unternehmen. Bei solchen Partner-APIs sind koordinierte Änderungen zwar immer noch möglich, doch sie erfordern bereits deutlich erhöhten Kommunikationsaufwand und die Auswirkungen von Änderungen sind nicht mehr überschaubar.

Bei öffentlichen APIs bietet der Provider seine API jedem an und kennt seine Consumer und deren Verwendung der API nicht. Für den Provider ist damit nicht absehbar, ob er durch Änderungen an seiner API Consumer bricht. Dies birgt für den Consumer immer das Risiko, dass seine Anwendung ohne eigenes Verschulden nicht mehr funktioniert.

 

Probleme impliziter Contracts

Die Funktionsweise von APIs ist oftmals nicht explizit spezifiziert, sondern basiert auf impliziten Annahmen oder Absprachen. Insbesondere interne APIs werden bei Bedarf entwickelt und auf den konkreten Anwendungsfall zugeschnitten. Benötigt die Implementierung eines neuen Features Daten oder Funktionalität aus mehreren Microservices, so werden neue interne APIs erstellt, die genau diesen Anwendungsfall abdecken. Die Funktionalität der APIs wird oftmals nur besprochen, selten jedoch explizit spezifiziert. Eine Dokumentation der Schnittstelle erfolgt oft erst hinterher. Dieses Vorgehen wird gerne damit gerechtfertigt, dass die API nur teamintern verwendet wird, die Auswirkungen einfach überschaubar sind und die Schnittstelle auch unkompliziert geändert werden kann. Die Entwicklungszyklen der beiden Komponenten sind damit aber aneinander gekoppelt und bei jeder Weiterentwicklung muss diese Kopplung berücksichtigt werden. Mit der Zeit entstehen immer weitere Schnittstellen, die Abhängigkeiten werden komplexer, die Übersicht geht verloren.

Auch bei Partner-APIs fehlt es oft an klarer Spezifikation. Ursprünglich intern konzipierte Schnittstellen entwickeln sich zum Beispiel zu Partner-APIs, weil vorhandene Funktionalität Kollegen aus anderen Teams zur Verfügung gestellt wird. Oftmals werden Partner-APIs nur informell besprochen. Dadurch fehlt es an konkreter Spezifikation. Problematisch ist hierbei nicht nur, dass die Weiterentwicklung der APIs eng aneinander gekoppelt wird, auch die initiale Entwicklung von Consumer und Provider ist eng aneinander gekoppelt. Die wirkliche Funktionalität der Schnittstelle ist für den Consumer erst dann ersichtlich, wenn die API fertig entwickelt ist. Wird bei der Entwicklung des Consumers abgewartet bis der Provider fertig entwickelt ist, so entsteht eine längere Entwicklungszeit als bei einer parallelen Entwicklung. Startet die Entwicklung des Consumers dagegen bevor der Provider fertiggestellt ist, so wird der Consumer auf Annahmen basierend entwickelt, was ebenfalls Zeit- und Kommunikationsaufwand bedeutet.

Öffentliche APIs sind in der Regel dokumentiert, sodass sie einfach verwendet werden können. Oft erfolgt die Spezifikation der API auch in Formaten, aus denen Clients generiert werden können, zum Beispiel OpenAPI[1]. Solche generierten Clients haben aber den Nachteil, dass sie auch Aspekte der API verwenden, die für den Consumer keine Relevanz haben und somit wartungsintensiver sind. Schreibt man den Client selbst, so benötigt man zum Testen wieder ein komplettes System. Alternativ generiert man Mocks, bei denen die Gefahr besteht, dass sie eigene Annahmen enthalten, die sich nicht mit der Funktionalität der API decken.

Problematisch bei allen drei API-Arten ist, dass eine reine Dokumentation kaum geeignet ist, um die Kompatibilität bei Weiterentwicklungen zu gewährleisten. Außerdem ist die Entwicklung der Services eng aneinander gekoppelt – der Consumer ist immer vom Provider abhängig, oder läuft Gefahr, eigene Annahmen in genutzte Mocks einzubauen. Für realistische Tests müssen daher immer alle Services, oft noch mit eigenen Abhängigkeiten, hochgefahren werden. Dadurch wird das Testen aufwändig, teuer und zeitintensiv. Die Entwicklungs- und Release-Geschwindigkeit nimmt ab.

 

Contract Testing

Eine Lösung für die beschriebenen Probleme ist Contract-Testing. Beim Contract-Testing vereinbaren Consumer und Provider einen expliziten Vertrag (Contract), der die Schnittstelle beschreibt. Aus dieser Beschreibung werden dann sowohl Test-Clients als auch Test-Provider generiert. Der Provider testet seine Implementierung gegen den Test-Client, der Consumer gegen den Test-Provider. Contracts definieren hierfür beispielhafte Requests. Für die Tests des Providers werden die definierten Requests gegen die API ausgeführt und die zurückgelieferten Antworten mit den erwarteten Antworten verglichen. Stimmen die erwartete Antwort und die tatsächliche Antwort nicht überein, so erfüllt der API-Provider die Erwartungen an die Schnittstelle nicht. Für die Consumer-Tests ruft der Consumer den Test-Provider auf, der die im Contract definierte Antwort zurückliefert. Wird keine Antwort zurückgeliefert, so war der vom Client erzeugte Request fehlerhaft. Wird die Antwort vom Client nicht korrekt verarbeitet, so ist die Implementierung des Clients ebenfalls fehlerhaft. Ein beispielhafter Auszug aus der Antwort einer RESTSchnittstelle, die Kundeninformationen zurückliefert ist in (Abb. 1) dargestellt.

Antwort Auszug API Version 1. (Abb. 1)

Der Contract dazu würde definieren, dass bei einem Get-Aufruf des /customers Endpunkts diese Antwort zurückgeliefert wird. Fehlt das Feld name in der Antwort, ist der Provider fehlerhaft. Ruft der Client nicht /customers auf oder kann die Antwort nicht verarbeiten, ist der Client fehlerhaft. Da weder der Provider einen realen Consumer benötigt, noch umgekehrt, können beide Seiten bereits vor der Fertigstellung der anderen API-Seite entwickelt und getestet werden. Die Entwicklungen von Consumer und Provider sind damit entkoppelt und können unabhängig voneinander vorangetrieben werden. Weiterhin hat dieses Vorgehen den Vorteil, dass sowohl API-Consumer als auch API-Provider gegen dieselbe Spezifikation der API testen. Einseitige Annahmen bleiben aus. Außerdem können auch Tests ohne Aufbau einer vollständigen Umgebung durchgeführt werden. Dadurch sind Tests deutlich einfacher und häufiger durchführbar. Dies hilft insbesondere bei der Weiterentwicklung der API.

Zunächst erscheinen fixe Contracts eine Weiterentwicklung der API zu behindern und wie ein Rückschritt hinsichtlich API-Evolution zurück in Zeiten von strikten API-Definitionen wie zum Beispiel mit RMI oder WSDL. Doch Contracts zwingen keinesfalls dazu, jede API-Änderung zu unterlassen. Stattdessen helfen sie dabei, inkompatible Änderungen bereits in frühen Tests aufzudecken. Wichtig für API-Evolution ist es, die Contracts nicht statisch zu sehen, sondern diese wenn nötig an neue Anforderungen anzupassen. Eine Weiterentwicklung der Schnittstelle aus (Abb. 1) liefert zum Beispiel Vorname und Nachname des Kunden einzeln zurück. Der Auszug aus der Antwort ist in (Abb. 2) dargestellt.

Antwort Auszug API Version 2. (Abb. 2)

 

Die Contracts werden hierfür angepasst, sodass sie das neue Verhalten abbilden, also die aktuelle Schnittstelle definieren. Dadurch bleiben aber auch die Contract-Tests des Providers erfolgreich, da diese die neue Schnittstelle gegen die neuen Contracts testen. Anders als oftmals in der Realität, ist in diesem einfachen Beispiel sofort ersichtlich, dass die Consumer, die das Feld name benötigen, nicht mehr mit dieser API-kompatibel sind. Geht diese neue API-Version live, sind alle Consumer, die das Feld name erwarten, defekt. Dieses Problem muss möglichst früh im Entwicklungszyklus entdeckt werden. Eine einfache Lösung um diese Inkompatibilität aufzudecken ist es, den Provider zusätzlich gegen die Contracts der Vorversion zu testen. In dieser wird das Feld name erwartet, das jetzt fehlt. Die Tests schlagen fehl.

Eine Möglichkeit mit dieser fehlenden Abwärtskompatibilität umzugehen ist, die Consumer ebenfalls anzupassen. Allerdings bringt dieses Vorgehen das Problem, dass Consumer und Provider koordiniert veröffentlicht werden müssen. Dies hat eine enge Kopplung von Consumer und Provider und einen deutlich erhöhten Aufwand zur Folge und ist daher nicht wünschenswert.

Eine zweite Option wäre das Einführen von API-Versionen. Neue Consumer verwenden die neue Version der API, die alte Version bleibt aber für bestehende Consumer weiterhin verfügbar. Auch dieses Vorgehen ist mit deutlich erhöhtem Aufwand verbunden, da der Provider zukünftig mehrere Versionen der API warten muss. Außerdem kann es weitere Änderungen der API geben, was wiederum neue Versionen bedeutet.

Die beste Option ist, die API abwärtskompatibel zu halten. Hierfür muss die Antwort alle 3 Felder enthalten, wie in (Abb. 3) dargestellt.

Antwort Auszug API Version 3. (Abb. 3)

 

Durch diese weiche Migration können API-Consumer und API-Provider unabhängig voneinander entwickelt und veröffentlicht werden, da die API-Versionen miteinander kompatibel sind. Werden die Clients gegen die aktuellen Contracts der API getestet, die das Feld name nicht mehr enthalten, so fällt die Inkompatibilität mit zukünftigen Versionen der API auf. Die Clients können dann angepasst werden, bevor das Feld name aus der API entfernt wird.

Der Provider bekommt durch die Tests gegen die Vorversion der Contracts die Sicherheit, dass inkompatible Änderungen seiner API bereits in Tests auffallen. Die Consumer erhalten die Möglichkeit API-Änderungen frühzeitig zu testen, ohne dass die API bereits den geänderten Stand aufweisen muss. Beide Seiten können durch den Einsatz von Contract-Tests profitieren und können API Inkompatibilitäten in Produktion verhindern.

Das beschriebene Vorgehen ist jedoch nur bei APIs möglich, bei denen Consumer und Provider Contract-Testing verwenden. Bei öffentlichen APIs muss das jedoch nicht der Fall sein. Der Provider kann, um seine Kompatibilität zu Vorversionen sicherzustellen zwar Contract-Testing nutzen, der Consumer kann sich in der Regel jedoch nicht darauf verlassen. Außerdem hat der Consumer meist keinen Zugriff auf die Contracts des Providers und umgekehrt. Für den Consumer bleibt damit das Risiko, dass die Schnittstelle inkompatibel zur eigenen Verwendung wird.

Eine Lösung für den Consumer ist es einen Contract-Proxy aufzubauen. Der Consumer schreibt hierfür Contracts, die seine Erwartungen an die Schnittstelle ausdrücken. Die aus diesen Contracts erzeugten Tests, werden dann gegen die API ausgeführt. Sind die Tests erfolgreich, enthalten die Contracts keine nicht erfüllten Annahmen. Der Consumer kann also gegen die aus den Contracts erzeugten Mocks testen. Führt der Contract-Proxy die API-Tests regelmäßig gegen die echte API aus, so werden Inkompatibilitäten zu den eigenen Annahmen zumindest automatisiert erkannt.

 

Consumer-Driven-Contract-Testing

Consumer-Driven-Contract-Testing ist eine Erweiterung von Contract-Testing. Hierbei drückt der Consumer einer API seine Erwartungen an die API in Form von Contracts aus. Dieses Vorgehen ist also nur möglich, wenn Consumer und Provider der API einander kennen, sprich nur bei internen und gegebenenfalls bei Partner-APIs. Dadurch, dass die Consumer die Contracts schreiben und dabei explizit ihre Erwartungen an die API ausdrücken, lässt sich gut erkennen, welche Komponenten der API genutzt werden. Ungenutzte Komponenten können dann problemlos geändert oder entfernt werden. Es besteht außerdem keine Möglichkeit, verwendete Komponenten zu entfernen, bevor nicht der letzte Consumer seine Contracts angepasst hat. (Abb. 4) zeigt eine vom Consumer getriebene Evolution einer API. Consumer A und Consumer B nutzen beide den /customers Endpunkt der API aus (Abb. 3) des vorherigen Abschnitts. Consumer A verwendet dabei das name Feld, Consumer B die Felder firstName und lastName. Der Contract mit Consumer A (Contract A1) enthält damit die Erwartung, dass die Antwort das Feld name zurückliefert. Der Contract mit Consumer B (Contract B) drückt die Erwartung aus, dass die Felder firstName und lastName zurückgeliefert werden. Erst wenn Consumer A angepasst wird und ebenfalls die neuen Felder verwendet, passt Consumer A seinen Contract entsprechend an (Contract A2).

Durch die Tests gegen die Vorversion der API, die weiterhin den Contract in der Version A1 enthält, kann der Provider seine API noch nicht direkt in der nächsten API-Version anpassen. Erst mit der übernächsten API-Version, kann der Provider das name Feld entfernen, da dann auch die Vorversion keine Contracts mehr enthält, die das Feld name erwarten. So verlängert sich auch die Übergangszeit und damit sind die Veröffentlichungen von Provider und Consumer noch besser entkoppelt.

API Evolution. (Abb. 4)

Consumer-Driven-Contract-Testing hilft darüber hinaus, die API minimal zu halten. Wird die API erst dann entwickelt, wenn sie benötigt wird und deckt nur die Anforderungen der Contracts ab, so erhält die API nur Komponenten, die auch wirklich von Consumern benutzt werden. Außerdem kann die API so von Anfang an testgetrieben entwickelt werden.

Sowohl testgetriebene Entwicklung als auch minimale Ansätze decken sich gut mit den heutigen Zielen schneller und zielorientierter Entwicklung. Ohne unnötige Komponenten wird die Codebasis kleiner und damit leichter zu warten. Durch die Tests werden Continuous-Integration und -Delivery unterstützt.

 

Spring-Cloud-Contract

Spring-Cloud-Contract[2] ist ein Projekt aus dem Spring-Cloud Umfeld, das Contract-Tests im Java und insbesondere im Spring Umfeld ermöglicht. Spring-Cloud-Contract unterstützt sowohl Contracts für http-Schnittstellen, als auch Contracts für Messaging mit Spring-Cloud-Stream. Contracts werden mit Spring-Cloud-Contract entweder in einer Groovy-DSL, oder in YML geschrieben. (Listing 1) zeigt ein Beispiel für einen Contract in der Groovy-DSL.

 

(Listing 1)


Dieser Contract spezifiziert im request Block, wie der Request des API-Consumers aussehen muss. Als HTTP-Methode wird GET festgelegt, der URL-Pfad wird als customers/1 definiert. Die Antwort des API-Providers wird im response Block definiert. Der erwartete Statuscode der API ist 200 (OK), der Header muss das Feld contentType enthalten, das den Wert application/json;charset=UTF-8 enthält. Für den Response-Body nimmt Spring-Cloud-Contract immer JSON als Format an. Die Groovy-Map stellt also ein JSON-Objekt mit den Feldern id, name, street und city dar. Das Spring-Cloud-Contract-Build-Plugin erzeugt aus solchen Contracts zum einen WireMock[3]-Stubs für die Consumer-Tests, als auch Testklassen für die API-Provider-Tests. Für die Provider-Tests gibt es die Möglichkeit MockMVC[4]-Tests zu generieren, die eine besonders einfache Integration in Spring Projekte bieten. Für Projekte ohne Spring bietet Spring-Cloud-Contract auch die Option die Testklassen mit einem JaxRS[5]-Client zu erzeugen. (Listing 2) zeigt einen Auszug aus dem erzeugten MockMVC-Test für den Contract aus (Listing 1).

 

(Listing 2)


Die erzeugten Testklassen erben von einer definierbaren Testklasse, hier ContractBase. Diese muss im eigenen Testcode vorhanden sein und ist dazu gedacht, die für den Test benötigten Abhängigkeiten bereitzustellen.

Für die Consumer-Tests wird WireMock genutzt. In einem Spring Projekt ist dank der @AutoConfigureStubRunner Annotation eine sehr einfache Integration in die eigenen Test möglich. Durch die Annotation wird für den Test ein lokaler WireMock-Stub-Runner gestartet. Gegen diesen können im Test die Requests des Consumers ausgeführt werden. Durch die Verwendung von WireMock als Stub-Runner ist die Verwendung der Stubs auch außerhalb des Java- / Spring-Umfelds einfach möglich. Start und Konfiguration des WireMock-Stub-Runners muss dann natürlich manuell erfolgen.

 

Weitere Dimensionen der API-Kompatibilität

Bei der Entwicklung von APIs ist es wichtig, sich der verschiedenen Dimensionen der API-Kompatibilität bewusst zu sein. Neben kompatibler Syntax müssen APIs auch in weiteren Dimensionen kompatibel zueinander sein. Ein Beispiel ist die Semantik der API. Eine Dauer kann zum Beispiel in unterschiedlichen Einheiten ausgedrückt werden. Ändert der Provider einer API beispielsweise die Einheit von Sekunden zu Millisekunden, so sind die Consumer ebenfalls inkompatibel, ohne dass dies durch Contract-Tests aufgedeckt werden kann.

Ein weiteres Problem sind kumulierte Updates. Contract-Tests können zwar sicherstellen, dass kompatible Zwischenversionen entstehen. Koordinierte Veröffentlichungen können aber dennoch notwendig werden, wenn die Zwischenversionen nicht in Produktion gebracht werden. Außerdem hat jeder Consumer einer API immer Erwartungen an den Zustand des Gesamtsystems. Wird zum Beispiel der Consumer einer API veröffentlicht, bevor der Provider veröffentlicht wird, so ist der Consumer nicht funktionstüchtig.

Contract-Testing ist ein hilfreiches Werkzeug für die Entwicklung und Evolution von APIs, jedoch liegt der Fokus auf Schemakompatibilität. Damit kann Contract-Testing syntaktische Kompatibilität einer API sicherstellen. Weitere Aspekte der API-Kompatibilität dürfen jedoch nicht vernachlässigt werden.

 

Fazit

Insbesondere für interne und Partner-APIs ist Contract-Testing ein sehr mächtiges Werkzeug, um die Entwicklung und Evolution von APIs zu vereinfachen und Inkompatibilitäten zu verhindern. Consumer-Driven-Contract-Testing ermöglicht darüber hinaus die testgetriebene Entwicklung minimaler APIs.

Providern öffentlicher APIs hilft Contract-Testing ihre Schnittstellen abwärtskompatibel zu halten. Consumer öffentlicher APIs können durch Contract-Testing Inkompatibilitäten zur eigenen Verwendung automatisiert erkennen. Contract-Testing kann damit bei jeder Nutzung von APIs einen Mehrwert bieten. Mit Spring-Cloud-Contract steht im Java- und Spring-Umfeld eine sehr einfache Möglichkeit zur Verfügung um Contract-Testing in eigenen Projekten umzusetzen.

 

Nikolai Neugebauer ist als Consultant für Digital Frontiers tätig. Sein Schwerpunkt liegt auf agiler Softwareentwicklung, vorwiegend im Java und Spring Umfeld. Außerdem beschäftigt er sich mit aktuellen UI Technologien im Webbereich.

Florian Pfleiderer beschäftigt sich als Senior Consultant bei Digital Frontiers mit agiler Softwareentwicklung. Seine Kunden berät er in den Bereichen Architektur, Microservices und Craftmanship.

https.digitalfrontiers.de

https://blog.digitalfrontiers.de

@nik101010

@pfleidfn

 

Quellen:

[1] https://swagger.io/docs/specification/about/

[2] https://spring.io/projects/spring-cloud-contract

[3] http://wiremock.org/

[4] https://spring.io/guides/gs/testing-web/

[5] https://jersey.github.io/

Redaktion


Leave a Reply