Consumer-driven Contract Testing - Einführung und Integration von Pact in eine AWS CodePipeline
In einem kürzlich erschienenen Blogartikel haben wir das Schnittstellenproblem im Allgemeinen beleuchtet. An dessen Ende haben wir kurz Pact als Contract Broker erwähnt.
In diesem Artikel wollen wir:
- Tiefer in das Thema Consumer-driven Contract Testing mit Pact einsteigen (Warum diese Art von Tests? Wie funktioniert Pact? Was sind die Vorteile gegenüber Ende-zu-Ende-Integrationstests?).
- Best Practices für den Entwicklungs- und Test-Workflow mit Pact beleuchten.
- Aufzeigen, wie diese Test-Strategie in eine CI/CD-Pipeline, basierend auf den AWS Developer Tools, architektonisch und organisatorisch umgesetzt werden kann.
Consumer-driven Contract Testing: Eine kurze Einführung
Verteilte Systeme, z. B. eine Microservice-Architektur, brauchen passende Schnittstellen, damit lose gekoppelte Komponenten miteinander kommunizieren können. Wenn wir hier von Komponenten sprechen, haben wir es grundsätzlich mit Konsumenten (consumers) auf der einen und Produzenten (producers) auf der anderen Seite zu tun. Letztere stellen Daten und/oder Funktionalität für Erstere bereit. Ein klassisches Beispiel wäre eine REST-API (producer), auf die ein Frontend (consumer) zurückgreift.
Für eine reibungslose Kommunikation muss - einfach gesagt - sichergestellt sein, dass Konsumenten und Produzenten "dieselbe Sprache sprechen". Konkret muss z. B. Einigkeit über Format und Struktur einer Antwort des Producers auf eine bestimmte Anfrage des Consumers herrschen. Eine Möglichkeit, diese gemeinsame Sprache sicherzustellen, ist über klassische Ende-zu-Ende-Integrationstests. Diese Art von Tests sind jedoch sehr aufwändig (es wird ein komplettes Deployment benötigt), langsam (Fehler werden sehr spät entdeckt) und erzeugen starke Abhängigkeiten in der Entwicklungsphase.
Wäre es nicht besser, die gemeinsame Spezifikation wie ein Unit-Test frühzeitig in der Entwicklung sicherzustellen?
Hier kommt Consumer-driven Contract Testing (CDCT) ins Spiel. Wie der Name schon vermuten lässt, geht hier die Spezifikation der Schnittstelle - der "Vertrag" - vom Konsumenten aus. Dies steht im Gegensatz zum vorherrschenden Ansatz, bei dem die Schnittstellen Produzenten-seitig dokumentiert werden (etwa über eine Spezifikation mittels OpenAPI o. ä.). Ein Consumer-driven Contract spezifiziert, welche Antwort(en) der Konsument von einem Provider auf bestimmte Anfragen erwartet. Damit der Contract beidseitig valide ist (matched), muss der Produzent diese Spezifikation bedienen können. Wichtig ist zu betonen, dass Contract-Tests keine funktionalen (Unit-)Tests ersetzen, sondern - als Teilmenge von Integrationstests - ergänzen.
Pact
Pact ist ein populäres Open Source Framework für CDCT. Pact unterstützt eine Vielzahl von Programmiersprachen, sowie (Test-)Frameworks, so zum Beispiel JavaScript mit Jest als Test-Framework. Die Contracts - Pacts genannt - werden in JSON serialisiert. In der jeweiligen Codebase werden die Contract-Tests über reguläre Unit-Tests abgebildet und zwischen Konsument und Produzent über ein Record-Replay-Verfahren validiert. Auf Konsumenten-Seite steht ein Mock-Provider bereit, um zu testen, dass der Code des Konsumenten mit den erwarteten Antworten des Providers umgehen kann. Die Aufrufe und die Antwort werden in einem Pact File gespeichert ("record"). Provider-seitig können die Aufrufe dann von einem simulierten Konsumenten wiedergegeben werden ("replay") und das Ergebnis mit der erwarteten Antwort (wie vom Consumer spezifiziert) verglichen werden (matching). Dieser Matching-Mechanismus von Pact implementiert den sog. "Robustheitsgrundsatz" (zugeschrieben Jonathan Postel, daher auch "Postel's law"), der besagt: "be conservative in what you do, be liberal in what you accept from others”. Tests für das eigene Verhalten dürfen (und sollen!) beliebig streng sein; gleichzeitig sollten wir möglichst flexibel mit dem sein, was wir von anderen erwarten (z. B. darf ein Produzent mehr Daten zurückliefern, als der Consumer benötigt). Unmittelbar aus diesem Grundsatz ergibt sich etwa auch, dass wir das Nicht-Vorhandensein eines Feldes nicht zusichern können.
Neben synchronen Kommunikationsmustern unterstützt Pact auch das Testen von asynchronen API-Aufrufen (z. B. über Message Queues).
Broker und Workflow
Theoretisch können Pacts direkt zwischen Konsumenten und Produzenten ausgetauscht und validiert werden (z. B. indem sie als Build-Artefakte zur Verfügung gestellt oder vom Konsumenten direkt in die Codebase des Produzenten gepusht werden). Ganz offensichtlich erzeugt dies starke Abhängigkeiten zwischen den Codebases. Auch (konsistente) Versionierung und die Verwaltung verschiedener Contracts ist so nur schwer umsetzbar.
Abhilfe schafft ein Broker als zentrale Stelle zur Ablage und Verwaltung der Contracts, der als Vermittler zwischen Konsumenten und Produzenten agiert. Im Kontext von Pact hat man für den Broker zwei Optionen: Die Open-Source-Variante, sowie die kommerzielle Variante Pactflow. Pactflow bietet eine Reihe zusätzlicher Features, z. B. werden auch Provider-gestützte Tests, Contracts auf Basis von OpenAPI sowie ein erweitertes User Management unterstützt. Leider ist aus unserer Sicht das Preismodell wenig attraktiv. Nicht zuletzt deswegen nutzen wir im Folgenden die Open-Source-Variante unter der Annahme, dass Broker nicht projektübergreifend, sondern stets pro Projekt genutzt werden sollen.
Der komplette Workflow für CDCT mit einem Broker ist in der folgenden Abbildung dargestellt und beinhaltet grundsätzlich folgende Schritte:
- Der Konsument erstellt Pact-Tests und implementiert die Logik, um die erwarteten Antworten des Produzenten zu verarbeiten.
- Der Konsument publiziert den Contract in Form eines Pact Files über den Broker.
- Der Provider entwickelt seinerseits eine zum Contract kompatible Schnittstelle und verifiziert somit den Pact.
- Beide Seiten können (ggf. in einem dedizierten Build-Schritt) mittels des Befehls
can-i-deploy
überprüfen, ob eine bestimmte Version eines Pacts deployed werden kann. Unter der Haube verwaltet Pact hierfür eine verification matrix, die die Ergebnisse der Validierung für die verschiedenen Versionen von Produzent, Konsument und Pact speichert. Neben der Versionierung können den Beteiligten in Pact auch Tags zugewiesen werden. Dies ist z. B. nützlich, um verschiedene Umgebungen (dev, test, prod, usw.) zu kennzeichnen.
Integration in AWS-Umgebung
Nachdem wir die grundsätzliche Funktionsweise von Consumer-driven Contract Testing im Allgemeinen und den Workflow mit dem Pact Broker im Speziellen beleuchtet haben, wollen wir uns nun anschauen, wie wir diesen Workflow im Kontext einer AWS-Umgebung umsetzen können.
Die grundlegende Architektur für die Implementierung von Pact im AWS-Umfeld ist in der folgenden Abbildung dargestellt:
Jedes Entwicklerteam (für Konsument und Produzent) arbeitet mit einer eigenen CI/CD-Pipeline, die CodeCommit, CodeBuild und CodeDeploy innerhalb einer CodePipeline nutzt. Der Fokus liegt im Folgenden auf CodeBuild, da in dieser Pipeline Stage die (verschiedenen) Tests stattfinden, unter anderem auch die Contract-Tests.
Idealerweise sind die Interaktionen mit dem Pact Broker in einen eigenen CodeBuild-Schritt ausgelagert (dargestellt durch weitere, transparente CodeBuild-Symbole vor den Contract-Tests), damit etwaige Fehler nicht andere Schritte der Pipeline scheitern lassen.
Broker
Der Pact Broker (inkl. dazugehöriger PostgreSQL-Datenbank) läuft auf einer EC2-Instanz. Alternativ wäre auch denkbar, die PostgreSQL-Datenbank über RDS/Aurora bereit zu stellen oder alle Komponenten als Container laufen zu lassen (Vorlagen und Anleitungen, auch für Docker Compose hier). Sollte der Broker als Container laufen, ist natürlich zu beachten, die Datenbank persistent zu gestalten, etwa durch Docker Volumes.
Ein potentieller Stolperstein an dieser Stelle ist die Konnektivität zum Broker aus CodeBuild heraus. Standardmäßig läuft CodeBuild isoliert. Beim Erstellen des Build Projects muss man diesem explizit die Zugehörigkeit zu einem VPC mit Subnetz zuweisen. Zu beachten ist hierbei, dass es sich um ein privates Subnetz handeln muss. Sollte sich der Broker in einem öffentlichen Subnetz (oder außerhalb der AWS-Cloud) befinden, muss an das private Subnetz, in dem sich der Broker befindet, ein entsprechendes NAT-Gateway angebunden werden.
Da die Open-Source-Variante des Brokers nur einfache Username/Passwort-Authentifizierung erlaubt und diese Daten mitunter im Klartext oder als Umgebungsvariablen in den Build-Skripten vorliegen, empfiehlt es sich, auch den Pact Broker in einem privaten Subnetz zu betreiben.
Kommunikation zwischen Entwicklerteams
Mindestens genauso wichtig wie die technisch-architektonische Umsetzung sind organisatorische Fragestellungen für die Zusammenarbeit der Entwicklerteams von Konsument und Produzent. Auch wenn in CDCT nicht nur die initiale Entwicklung, sondern auch Änderungen stets vom Konsumenten ausgehen (siehe diese Dokumentation), sollten Pacts stets als eine Grundlage für die Zusammenarbeit zwischen Produzenten und Konsumenten und nicht als starres "Vertragswerk" im eigentlichen Sinne gesehen werden. Aus diesem Grund entscheiden wir uns explizit gegen das automatisierte Triggern von Build Pipelines (wie es etwa mit AWS EventBridge möglich wäre) in Reaktion auf z. B. das Publishen eines neues Pacts (mit der möglichen Konsequenz, dass andere Builds scheitern). Hier könnten auch leicht Deadlocks entstehen (wer wartet jetzt auf wen zur Erfüllung/Neugestaltung des Contracts?) Stattdessen schlagen wir vor, andere Werkzeuge zur Unterstützung des Entwicklungs-Workflows zu nutzen. Geeignet erscheint hierfür SNS als Publish-Subscribe Messaging-Pattern. Je nach Größe des Projektes und wie die Nachrichten (ggf. automatisiert) weiterverarbeitet werden, kommt hier ein einzelnes Topic oder eine Aufteilung nach Publisher und/oder Art des Ereignisses (Erfolg/Scheitern eines Builds, publizieren/verifizieren eines Pacts, etc.) in Frage. SNS gibt uns hier eine große Flexibilität für die Weiterverarbeitung der Nachrichten. Über den AWS Chatbot als Subscriber ließen sich beispielsweise auch aus SNS in einen Slack Channel speisen, und damit einfach in bestehende Kommunikationsstrukturen integrieren. Alternativ zu SNS könnten auch (eigene) Webhooks über Lambda-Funktionen o. ä. realisiert werden.
Fazit
Consumer-driven Contract Tests sind für eine verteilte Microservice-Architektur eine attraktive Alternative zur klassischen Produzenten-getriebenen Spezifikation von Schnittstellen oder zu aufwändigen Ende-zu-Ende-Integrationstests. Pact ist in diesem recht neuen Umfeld ein durchaus ausgereiftes Tool, auch wenn leider der Open-Source-Variante des Brokers viele wichtige Features fehlen. Hier erhoffen wir uns für die Zukunft stärkere Impulse aus der Community. Auch haben wir festgestellt, dass sich der Workflow mit Pact in einer CI/CD-Pipeline nicht zu 100 % sinnvoll automatisieren lässt. Schlussendlich mag auch der grundsätzliche Ansatz des "consumer-driven" nicht immer praktikabel sein. Sicherlich verhindert dies (in der Theorie) einseitige "breaking changes" durch Produzenten, aber offen bleibt, wie sich dieser Ansatz mit notwendigen Weiterentwicklungen von Produzenten verträgt, symbolisiert durch die Frage: Wie lassen sich Änderungen der Konsumenten durchsetzen?