Verträge sind einzuhalten

Microservices: Consumer-driven Contract Testing mit Pact

Tobias Bayer, Hendrik Still

© iStockphoto.com / Arturbo

„Pacta sunt servanda“, oder zu deutsch „Verträge sind einzuhalten“: Was schon im Mittelalter galt, soll in der modernen Welt der Software auch verbindlich sein. Mithilfe von (API-)Verträgen, die nicht nur durch einen, sondern mehrere Vertragspartner gestaltet werden, lassen sich Microservices-Architekturen einfach und effizient testen und weiterentwickeln.

„Microservices“ ist eines der Schlagwörter schlechthin, wenn im Moment über moderne Softwarearchitektur gesprochen und geschrieben wird. Dabei handelt es sich um einen Architekturstil, bei dem die Geschäftsfunktionen einer Anwendung auf viele kleine Services verteilt werden. Jeder Service läuft in seinem eigenen Prozess und ist typischerweise für genau eine klar definierte Geschäftsfunktion zuständig. Die Services kommunizieren untereinander meist über Webtechnologien wie z. B. HTTP/REST oder aber auch über einen Message Bus. Eine Microservices-Applikation erfüllt die an sie gestellten Anforderungen durch das Zusammenspiel der einzelnen Microservices. So kann eine Applikation zur Darstellung von Produktinformationen z. B. aus folgenden Microservices bestehen: Beschreibung, Preis, Kundenbewertungen und Lagerbestand.

Herausforderungen beim Testen

Vorteile einer Microservices-Architektur sind unter anderem Skalierbarkeit (sowohl der Software selbst als auch des Entwicklungsteams), Ausfallsicherheit, Sprach- und Technologieunabhängigkeit sowie erhöhte Flexibilität. Allerdings bringt ein solches verteiltes System auch Nachteile mit sich, z. B. den komplizierteren Betrieb, Codeduplikation, Netzwerklatenz, verteilte Transaktionen, zusätzliche Schnittstellen und schwierigere Testbarkeit. Für all diese Probleme existieren aber Lösungsansätze. In diesem Artikel sollen Consumer-driven Contracts als Lösungsmöglichkeit für die beiden letztgenannten Nachteile  der zusätzlichen Schnittstellen und Testbarkeit vorgestellt werden.

Beim Testen einer Anwendung, die aus Microservices besteht, ergeben sich gegenüber einer monolithischen Architektur eine Reihe spezieller Herausforderungen. Die auf den unteren Ebenen angesiedelten Tests (beispielsweise Unit-Tests) können weiter wie gewohnt entwickelt werden. Damit aber eine Applikation ihre Aufgabe erfüllen kann, ist in einer Microservices-Architektur stets ein Zusammenwirken einzelner Microservices notwendig. Sobald also Schnittstellen zu anderen Microservices im Spiel sind oder die Antworten eines anderen Microservice benötigt werden, um die eigene Funktionalität zu testen, stellt sich die Frage, wie diese Abhängigkeiten zur Verfügung gestellt werden können.

Üblicherweise will man zum Testen eines Microservice nicht die komplette Landschaft der Services hochfahren. Dadurch würden die großen Vorteile einer Microservices-Architektur verspielt werden, nach denen einzelne Microservices unabhängig entwickelt, gewartet und in Betrieb genommen werden können. Der einzelne Service soll also möglichst isoliert getestet werden. Andererseits soll aber sichergestellt werden, dass die Services im Zusammenspiel wie erwartet funktionieren. Diese Herausforderung lässt sich mithilfe des Consumer-driven Contract Patterns lösen.

Provider Contracts vs. Consumer Contracts

Das Konzept der Consumer Contracts ist zwar nicht neu, gewinnt jedoch im Zuge des Microservices-Architekturstils an Aktualität. Ein Consumer Contract beschreibt aus der Sicht eines Aufrufers, wie die Antwort strukturiert sein soll, die er beim Aufruf eines Service erwartet.

Im Vergleich dazu sind Provider Contracts besser bekannt. Hier beschreibt ein Service, welche Antworten er auf welche Anfragen liefern wird. Typische Vertreter sind z. B. die WSDL eines Web Service oder die Swagger-Dokumentation eines REST-Service. Provider Contracts stellen meist eine umfassende Dokumentation aller Fähigkeiten eines Service dar. Der anbietende Service stellt diesen Schnittstellenvertrag selbst zur Verfügung.

Ein Consumer Contract ist dagegen die Spezifikation eines Ausschnitts aus der Gesamtfunktionalität eines aufgerufenen Service, den ein spezieller Aufrufer benötigt, um wiederum die an ihn gestellten Anforderungen erfüllen zu können.

Abb. 1: Consumer Contracts und Consumer-driven Contract

Abb. 1: Consumer Contracts und Consumer-driven Contract

Der Consumer-driven Contract eines Service setzt sich aus allen Consumer Contracts seiner Aufrufer zusammen (Abb. 1). Es ist deshalb möglich, dass ein Service gar nicht seinen vollständigen Provider Contract implementieren muss, weil es zu einem bestimmten Zeitpunkt im Consumer-driven Contract keinen Aufrufer einer bestimmten Funktionalität gibt. Eine so aufgebaute Microservices-Applikation unterstützt also auch das YAGNI-Prinzip, indem eine in einem Service bereits angedachte oder spezifizierte Funktionalität erst implementiert werden muss, sobald im Consumer-driven Contract ein Aufrufer dieser Funktionalität auftaucht.

Ein solches Vorgehen ist vor allem für eine Umgebung sinnvoll und geeignet, in der Kontrolle sowohl über Consumer als auch über Provider besteht, z. B. weil die Services von Teams der gleichen Organisation entwickelt werden.

Testen mit Consumer Contracts

Consumer Contracts können im Test auf zwei Arten verwendet werden. Beim Testen des Consumers kann aus ihnen ein Provider Stub erzeugt werden, der dem Consumer im Test genau die Antworten liefert, die er vom Provider erwartet. Zum Testen des Providers werden Requests aus dem Contract gegen die Schnittstelle des Providers erzeugt und die gelieferten Antworten geprüft (Abb. 2). Sind beide Tests erfolgreich, können sowohl Consumer als auch Provider bedenkenlos in Betrieb genommen werden. Bricht der Test des Providers ab, darf dieser nicht deployt werden, weil er die an ihn gestellten Anforderungen nicht (mehr) erfüllt. Diese Situation tritt z. B. ein, wenn ein Provider ein Feld aus seiner Antwortdatenstrukur entfernen will, aber mindestens ein Consumer dieses Feld noch erwartet.

Schlagen Tests des Consumers fehl, kann er nicht (mehr) mit den Antworten umgehen, die der Provider weiterhin liefern wird (solange dessen Tests gegen den Consumer Contract erfolgreich sind). Er darf deshalb ebenfalls nicht deployt werden.

Abb. 2: Testen mit Consumer Contracts

Abb. 2: Testen mit Consumer Contracts

Mit dieser Strategie ist es nun möglich, Services relativ unabhängig voneinander auf ihr Zusammenspiel hin zu testen, ohne die jeweiligen Partnerservices hochfahren zu müssen. Diese Tests liefern im Regelfall sehr schnelles Feedback bei der Änderung einzelner Services bei hoher Konfidenz über die Integrität der Gesamtapplikation.

Damit diese Sicherheit besteht, müssen alle Consumer ihre Contracts aktuell halten und den Providern für ihre Tests ohne Verzögerung zur Verfügung stellen. Dies kann beispielsweise über ein zentrales Repository geschehen, in dem die Consumer ihre Contracts veröffentlichen und das von den Providern beim Testen auf Neuerungen abgefragt wird.

Praktische Implementierung

Wie geschieht die praktische Umsetzung von Consumer-Contract-Tests? In einem Monolithen ließe sich das ganze Prinzip durch einfache JUnit-Tests implementieren. Allerdings gestaltet sich dies im Fall von Microservices-Anwendungen wesentlich aufwändiger. Denn hierbei müssen nicht nur einzelne Objekte und Methodenaufrufe gemockt werden, sondern ganze REST-Services, die über HTTP aufgerufen werden sollten. Zur Lösung dieser Aufgabe gibt es bereits Open-Source-Tools. Einer der bekanntesten Vertreter ist ein noch recht junges Tool mit dem passenden Namen Pact. Es wurde ursprünglich in Ruby entwickelt, ist aber bereits für die unterschiedlichsten Plattformen wie JavaScript, .NET und auch für die JVM portiert worden.

Im Folgenden wollen wir uns anhand einer einfachen Anwendung anschauen, wie man mit Pact-JVM die Schnittstelle zwischen Microservices durch Consumer-driven Contracts testen kann.

Anwendungsfall

Getestet wird die Schnittstelle zwischen dem ProductDetailsService, der detailliertere Informationen, wie z. B. die Beschreibung zu einem Produkt, bereitstellt und dem ProductService, der diese Informationen benötigt. Somit ist der ProductService der Consumer, und der ProductDetailsService nimmt in diesem Fall die Rolle des Providers ein. Die Kommunikation findet über eine REST-Schnittstelle statt, die der ProductDetailsService bereitstellt.

Um das Erzeugen und Konsumieren des Service zu vereinfachen, verwenden wir in unserem Beispiel Spring Boot und legen für jeden Service ein eigenes Gradle-Projekt an. Zum detaillierten Nachvollziehen des Codes stehen beide Projekte auf GitHub zur Verfügung.

Implementierung des Consumers

Nach dem Consumer-driven-Ansatz wird zuerst der Consumer, also unser ProductService, entwickelt. Da die Kommunikation zwischen ihm und dem Provider getestet werden soll, ist der Aufruf der REST-Schnittstelle der interessanteste Teil der Implementierung. Wie Listing 1 zeigt, wurde diese Schnittstelle in die Klasse ProductDetailsFetcher gekapselt und enthält nur die Methode fetchDetails. Sie verwendet ein RestTemplate, um die Ressource von dem gegebenen URI zu laden und aus den Antwortdaten eine Instanz der Klasse ProductDetails zu erstellen. Um den Quellcode kompakt zu halten, wurde in Listing 1 auf die Fehlerbehandlung verzichtet.

Listing 1
public class ProductDetailsFetcher {
    public ProductDetails fetchDetails(URI productDetailsUri) {
        return new RestTemplate().getForObject(productDetailsUri,
                ProductDetails.class);
    }
}

Aber wie testet man diesen Teil der Anwendung? Da noch kein Provider existiert, muss die erwartete Schnittstelle aus Sicht des Consumers definiert werden. Hier kommt Pact ins Spiel. Dazu wird die Klasse ProductDetailsServiceConsumerTest (Listing 2) erstellt. Sie erbt von der abstrakten Klasse ConsumerPactTest, die von Pact-JVM bereitgestellt wird. Der Test fordert die Implementierung der Methode createFragment. Zurückgegeben wird ein PactFragment, das alle Interaktionen enthält, die während des Tests anfallen, also jeden Request des Consumers und die erwarteten Antworten des Providers dazu. Die Interaktionen können recht angenehm über die ConsumerPactBuilder DSL definiert werden. Der als Parameter übergebene builder erwartet immer einen Methodenaufruf uponReceiving, gefolgt von einem willRespondWith. In unserem Beispiel erwarten wir vom Provider, dass er auf die GET-Anfrage auf den Pfad /productdetails/1 mit dem Statuscode 200 und dem erwarteten JSON als Body antwortet.

Mit dem PactFragment haben wir die Erwartung des Consumers an den Provider definiert. Nun können wir in der Methode runTest testen, ob unsere Implementierung des ProductDetailsFetcher den definierten Contract einhält. Diese Methode wird ausgeführt, nachdem Pact den Provider Stub auf Basis des PactFragments gestartet hat und durch den Parameter url die Adresse des Stubs mitgeteilt bekommt.

Das Testen geschieht, wie bei Unit Tests üblich, durch das Ausführen der Komponenten und Überprüfen der Resultate durch asserts. Wenn nun der Consumer eine nicht erwartete Anfrage stellt, wird der Test fehlschlagen. Ebenso wird getestet, ob der Consumer mit der Antwort des Stubs zurechtkommt.

Damit eine Zuordnung des Contracts zu einem Consumer und einem Provider möglich ist, müssen noch deren Namen über die Methoden providerName und consumerName festgelegt werden.
Um nun den Test durch Pact JVM auszuführen, müssen wir nur noch die Dependency testCompile ‚au.com.dius:pact-jvm-consumer-junit_2.11:2.1.7‘ zum Gradle-Projekt des ProductDetailsService hinzufügen.

Listing 2
public class ProductDetailsServiceConsumerTest extends ConsumerPactTest {
    @Override
    protected PactFragment createFragment(ConsumerPactBuilder.PactDslWithProvider builder) {
        Map<String, String> headers = new HashMap<>();
        headers.put("Content-Type", "application/json;charset=UTF-8");
        return builder.uponReceiving("a request for product details")
                .path("/productdetails/1")
                .method("GET")
                .willRespondWith()
                .headers(headers)
                .status(200)
                .body("{\"id\":1,\"description\":\"This is the description for product 1\"}")
                .toFragment();
    }
    @Override
    protected String providerName() {
        return "Product_Details_Service";
    }
    @Override
    protected String consumerName() {
        return "Product_Service";
    }
    @Override
    protected void runTest(String url) {
        URI productDetailsUri = URI.create(String.format("%s/%s/%s", url, "productdetails", 1));
        ProductDetailsFetcher productDetailsFetcher = new ProductDetailsFetcher();
        ProductDetails productDetails =  productDetailsFetcher.fetchDetails(productDetailsUri);
        assertEquals(productDetails.getId(), 1);
    }
}

Das Pact-File

Der ProductDetailsServiceConsumerTest bietet alle Informationen, die nötig sind, um den Contract für die Schnittstelle zu erstellen und die eigenen Komponenten zu testen, die auf diese Schnittstelle zugreifen. Der Test kann nun mit gradle test im Verzeichnis des ProductService ausgeführt werden. Dabei wird der Provider Stub gestartet und die eigenen Komponenten gegen diesen getestet. Zusätzlich erzeugt Pact während des Tests noch eine JSON-Datei – das so genannte Pact File. In unserem Beispiel ist es nach dem Test unter  ./target/pacts/Product_Service-Product_Details_Service.json zu finden. Dieses File stellt den eigentlichen Contract dar und kann verwendet werden, um zu prüfen, ob er vom Provider eingehalten wird. Es enthält alle Requests und Antworten, die im Consumer Test definiert wurden. Listing 3 zeigt das Pact File für den Contract zwischen ProductDetailsService und ProductService. Durch die Plattformunabhängigkeit des Pact Files und die zahlreichen Portierungen von Pact ist es ebenfalls möglich, dieses auch mit Microservices zu verwenden, die in anderen Programmiersprachen entwickelt wurden. Somit unterstützt Pact die Entwicklung polyglotter Anwendungen, die ein oft genannter Vorteil des Microservices-Architekturstils ist.

Listing 3
{
  "provider" : {
    "name" : "Product_Details_Service"
  },
  "consumer" : {
    "name" : "Product_Service"
  },
  "interactions" : [ {
    "description" : "a request for product details",
    "request" : {
      "method" : "GET",
      "path" : "/productdetails/1"
    },
    "response" : {
      "status" : 200,
      "headers" : {
        "Content-Type" : "application/json;charset=UTF-8"
      },
      "body" : {
        "id" : 1,
        "description" : "This is the description for product 1"
      }
    }
  } ],
  "metadata" : {
    ...
  }
}

Implementierung des Providers

In Form des Pact-Files haben wir den Consumer Contract und können mit diesem nun den passenden Provider entwickeln. Die Klasse Application im Projekt des ProductDetailsService erstellt hierfür die entsprechende REST-Schnittstelle. Dies gelingt dank Spring recht einfach (Listing 4). Die Methode fetchProductDetails wird bei jedem Request ein serialisiertes ProductDetails-Objekt zurückgeben. Damit unsere Beispielanwendung übersichtlich bleibt, erhalten alle Produkte einen statischen Text als Beschreibung.

Listing 4
@RestController
public class Application {
    @RequestMapping(value = "/productdetails/{id}", method = RequestMethod.GET)
    public ProductDetails fetchProductDetails(@PathVariable final long id) {
        return new ProductDetails(id, "This is the description for product " + id);
    }
    public static void main(final String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Um zu verifizieren, ob unser Provider den gegebenen Contract einhält, müssen die Anfragen des Pact Files an die REST-Schnittstelle gestellt werden. Antwortet der Service so wie im Pact definiert, gilt der Contract als erfüllt. Es ist sinnvoll, die Verifikation in den Build-Prozess des Service zu integrieren. Dafür bietet Pact-JVM ein Plug-in für Gradle an. Es wird mit apply plugin: ‚au.com.dius.pact‘ zum Gradle-Projekt des ProductDetailsService hinzugefügt. Listing 5 zeigt, wie mit dem Plug-in ein Pact in Gradle definiert wird. So muss die Lokation des Providerservice und der Pfad zum Pact-File angegeben werden. Neben dem Verwenden von Dateien ist es auch möglich, auf entfernte Pact-Files mit url() zuzugreifen. Dadurch können beispielsweise die Pact-Files auf einem zentralen Webserver bereitgestellt werden.

Damit Pact unseren Service testen kann, muss er erst gestartet werden. Da unser Projekt Spring Boot verwendet, können wir unseren Provider einfach mit dem Befehl gradle bootRun starten. Sobald der Service erreichbar ist, lassen wir mit gradle pactVerify die Verifikation durchführen. So ist es möglich, die Schnittstelle des ProductDetailsService entkoppelt vom ProductService zu testen.

Listing 5
pact {
   serviceProviders {
      productDetailsServiceProvider {
         protocol = 'http'
         host = 'localhost'
         port = 10100
         path = '/'
         hasPactWith('productServiceConsumer') {
            pactFile = file("../product-service/target/pacts/Product_Service-Product_Details_Service.json")
         }
      }
   }
}

Fazit

Consumer-driven Contracts entschärfen eine der größten Herausforderungen in Microservices-Architekturen. Die Gefahr, die Vorteile der getrennten Deploybarkeit durch eng gekoppelte Tests wieder zu verlieren, wird durch diese Art der Schnittstellenspezifikation und des Testaufbaus stark vermindert. Trotzdem bleibt eine hohe Sicherheit über das korrekte Zusammenspiel der Microservices erhalten. Unser Beispiel hat gezeigt, dass mit Pact ein vergleichsweise ausgereiftes Tool zur Unterstützung von Consumer-driven Contract Testing in (polyglotten) Microservices-Umgebungen existiert.

Verwandte Themen:

Geschrieben von
Tobias Bayer
Tobias Bayer
Tobias Bayer ist Softwarearchitekt und Senior Developer bei der inovex GmbH. Seine Schwerpunkte liegen auf der Entwicklung von skalierbaren Webapplikationen mit Java und mobilen Apps für iOS. Außerdem beschäftigt er sich mit funktionaler Programmierung in Clojure. tobias.bayer@inovex.de
Hendrik Still
Hendrik Still
Hendrik Still ist Werkstudent im Bereich Application Development bei der inovex GmbH. Zurzeit beschäftigt er sich mit den Problemstellungen von Microservices-Architekturen und deren Umsetzung mit Docker. hendrik.still@inovex.de
Kommentare

Hinterlasse einen Kommentar

2 Kommentare auf "Microservices: Consumer-driven Contract Testing mit Pact"

avatar
4000
  Subscribe  
Benachrichtige mich zu:
Timo
Gast

Schwieriger wird es aus meiner Sicht in realistischeren Szenarien, wo das Ergebnis des Providers in der Regel von weiteren externen Systemen abhängig ist (z.B. eine Datenbank oder einem weiteren Microservice). Dann muss man isolierte Umgebungen bereitstellen, damit die gewonnene Entkopplung nicht doch wieder an anderer Stelle verloren geht.

Ein bisschen schade ist, dass Pact die Provider Verifikation nur auf Ende-zu-Ende Ebene erlaubt. Damit wird die Konstruktion der Isolation aus meiner Sicht verkompliziert, da man nicht mit klassischen Fixtures arbeiten kann. Ein programmatischer Ansatz würde das Setup vereinfachen.

Herr Klugscheisser
Gast

„pacta sunt servanda“ war ein aussenpolitisches Prinzip der Römer – das war etwas vor dem Mittelalter 🙂