/ contract test

Contract Testing in Practice

Mit dem Einsatz von Micro Services gewinnt die Kommunikation über Schnittstellen an Bedeutung. In den letzten Jahren hat sich dabei der Einsatz von lose gekoppelten Technologien gegenüber binär gekoppelten Technologien durchgesetzt. Es stellt sich jedoch die Frage, wie die Kompatibilität der Schnittstellen getestet und sichergestellt werden kann.

alt

Für das Testen von lose gekoppelten Schnittstellen wurden folgende Ziele ausgerufen:

  1. Unabhängige Entwicklung von Client und Server
    Client und Server sollen sich unabhängig voneinander entwickeln können, solange die Änderungen weiterhin kompatibel sind, d.h. der Server soll neue Attribute einführen können, und weiterhin kompatibel zu bestehenden Clients sein. Jeder Client soll neue Attribute bereits mitschicken können, ohne dass der Server Fehler produziert.

  2. Erkennen von Inkompatibilitäten
    Sobald der Client Anfragen verschickt, die vom Server nicht mehr verstanden werden können oder der Server so antwortet, dass der Client die Antwort nicht mehr verstehen kann, möchte man so früh wie möglich informiert werden.

Contract Tests

Bei der Verwirklichung dieser Ziele sind wir auf das Konzept des „Consumer Driven Contract Testing“ gestoßen (im Folgenden „Contract Testing“ bzw. „Contract Tests“). Die konkrete Einbettung in eine CI-Pipeline ist bereits hier beschrieben. Daher soll das Vorgehen nur kurz zusammengefasst werden:

  1. Beim Deployment einer neuen Version des Consumers werden die Contract Tests des Consumers gegen die Produktiv-Version des Producers getestet. Schlägt ein Test fehl, kann die neue Version des Clients nicht deployed werden.

  2. Beim Deployment einer neuer Version des Producers wird überprüft, ob alle Consumer mit der neuen Version kompatibel sind.

Dazu wird der Producer jeweils in einer isolierten Version, d.h. ohne weitere Service-Aufrufe, dynamisch hochgefahren und mit den Contract Tests befeuert. Dieser Prozessschritt ist sowohl auf Seiten des Producers als auch des Consumers in die jeweiligen CI-Pipelines integriert, so dass inkompatible Versionen von Consumer und Producer nicht in den Produktivbetrieb gehen.

Testdaten

In unserer Domäne haben Anfragen nicht selten sehr viele Attribute und die Antwort fällt oft nicht weniger komplex aus. Es ergeben sich somit viele verschiedene Testszenarien, die möglichst durch kleinteilige Tests abgedeckt werden sollen. Schnell stellte sich daher die Frage, wie man die Testdaten für diese Contract Tests möglichst nur an einer Stelle vorhalten muss. Denn oftmals waren für Consumer und Producer unterschiedliche Teams verantwortlich, so dass das redundante Vorhalten der Testdaten auf Consumer und Producer schnell zu einer Bremse in der Schnittstellenentwicklung führte.

Wir versuchten dem geschilderten Problem mittels einer dritten Partei Herr zu werden, welche die Testdaten für die Dauer eines Contract Tests vorhält. Dafür hielten wir Mountebank geeignet, ein leichtgewichtiger Node-Server, welcher mit sog. Impostern Test-Doubles bereitstellt. Verschiedene Client-Libraries für diverse Programmiersprachen erleichtern die Erzeugung dieser Imposter. Nachfolgend sind Test-Doubles für den Test eines Requests und einer Response abgebildet. Dazu wird ein einfaches Beispiel zum Speichern und Abrufen von Kontaktinformationen verwendet.

Response-Code für das Testen des Requests im Erfolgsfall

{
  "predicates": [
    {
      "equals": {
        "path": "/contactinformation",
        "method": "POST",
        "body": "{\"firstName\":\"Hans\",\"lastName\":\"Dampf\",\"email\":\"dampf@me.com\"}"
      }
    }
  ],
  "responses": [
    {
      "is": {
        "headers": {},
        "body": "",
        "statusCode": 200
      }
    }
  ]
}

Response-Code für das Testen des Requests im Fehlerfall

{
  "predicates": [],
  "responses": [
    {
      "is": {
        "headers": {},
        "body": "",
        "statusCode": 400
      }
    }
  ]
}

Response für das Testen einer Response im Erfolgsfall

{
  "predicates": [
    {
      "equals": {
        "path": "/contactinformation/anyId",
        "method": "GET"
      }
    }
  ],
  "responses": [
    {
      "is": {
        "headers": {
          "Content-Type": "application/json"
        },
        "body": "{\"firstName\":\"Hans\",\"lastName\":\"Dampf\",\"email\":\"dampf@me.com\"}",
        "statusCode": 200
      }
    }
  ]
}

Response für das Testen einer Response im Fehlerfall

{
 "predicates": [],
 "responses": [
   {
     "is": {
       "headers": {},
       "body": "",
       "statusCode": 400
     }
   }
 ]
}

Die Ausführung der Contract Tests läuft nun nach folgendem Schema ab, welches im Folgenden dargestellt und erläutert ist:

alt

1) Die Orchestrierung der Contract Tests übernimmt eine Contract Tester Instanz, ein eigenständiges Projekt, welches alle Beteiligten des Contract Tests kennt und den Ablauf orchestriert. Diese wird auch durch den CI-Build aufgerufen. Sie fährt den Producer hoch, in der Regel ein Docker-Container, und übergibt die URL des Mountebank-Imposters. Für den Producer muss dabei ein spezieller Modus vorgesehen sein, in dem bei Eintreffen eines Requests keine weiteren Kollaborateure aufgerufen werden, sondern der Test-Double von Mountebank abgerufen wird.

2) In einem nächsten Schritt wird Mountebank gestartet.

3) Die Contract Tests werden gestartet, nachdem ihnen mitgeteilt wurde, unter welcher URL der Mountebank-Imposter zu speichern ist.

4) Der Contract Test erstellt den Imposter und hinterlegt diesen in Mountebank, d.h. entweder eine Response, die auf den eintreffenden Request zurückgegeben werden soll (beim Test einer Response) oder nur einen Response Code, der auf den eintreffenden Request zurückgegeben werden soll (beim Test eines Requests).

5) Der jeweilige Contract Test setzt den Request über den letzten Kollaborateur des Consumers ab, meist indem der Contract Test direkt den jeweiligen Service aufruft.

6) Beim Eintreffen des Requests beim Producer wird Mountebank nach einem Matching für den erhaltenen Request gefragt. Es wird die gleiche Schnittstelle verwendet, die auch produktiv im Einsatz ist. Allerdings ist der erste Kollaborateur des Producers ein speziell für das Contract Testing vorgesehener Service. Jetzt sind folgende Fälle zu unterscheiden:

a) Testen, ob eine Antwort, so geliefert wird, wie sie vom Client erwartet wird
Auf der Producer-Seite wird der in Mountebank hinterlegte Imposter abgerufen und dessen Response durch das Objektmodell an den Contract Test zurückgegeben. Dort kann nun geprüft werden, ob die Antwort den Erwartungen entspricht. Enthält die Antwort mehr Attribute als vom Client erwartet, z.B. weil die Schnittstelle mehr Attribute vorhält, so ist das unkritisch für den Consumer. Fehlen Attribute, z.B. weil Attribute versehentlich entfernt oder umbenannt wurden, so sind Consumer und Producer nicht kompatibel und der Test schlägt fehl.

b) Testen, ob eine Anfrage, so verstanden wird, wie sie vom Client verschickt wird
Der Producer schickt den erhaltenen Request zwecks Request-Matching an Mountebank und gibt den hinterlegten Response-Code zurück. Auch hier wird die Kompatibilität von Consumer und Producer anhand des Objektmodells des Producers überprüft. Stimmt der im Producer eintreffende Request mit dem in Mountebank hinterlegten Request überein, so konnte der Producer den Request des Consumers genauso verstehen, wie es der Consumer erwartet hat. Andernfalls sind Consumer und Producer nicht kompatibel.
Für Schnittstellen, bei denen sowohl Request als auch Response getestet werden sollen, kann hier sowohl Response als auch Response-Code zurückgegeben werden.

Eine beispielhafte Implementierung anhand zweier Spring-Boot-Apps findet sich hier.

Grenzen dieses Vorgehens
Auf Producer-Seite und am besten auch auf Consumer-Seite müssen für den versandten Request und die erhaltene Response Objektmodelle vorhanden sein. Ansonsten entstehen unweigerlich Testlücken.
Gibt der Producer ungeprüft Daten seiner Kollaborateure zurück, so wird der Contract Test niemals fehlschlagen, da nun nur die im Mountebank-Imposter hinterlegte Response zurückgegeben wird, ohne Vorher durch das Objektmodell in ein Korsett gezwungen worden zu sein.
Werden hingegen die Requests auf Consumer-Seite auf Basis untypisierter Daten erzeugt, kann auch hier eine Testlücke entstehen. Es kann nicht sichergestellt werden, dass der letzte Kollaborateur des Consumers mit den gleichen Daten aufgerufen wird, wie im Contract Test.

Vorteile

  • Die Testdaten müssen nur auf Consumer-Seite gepflegt werden. Die Tests können somit vom Consumer nach Belieben geschnitten werden. Kleinteilige Tests, die insbesondere im Fehlerfall einfacher zu verstehen sind, werden dadurch gefördert, da auf Producer-Seite keine Testdaten für unterschiedliche Ausprägungen von Request und Response vorgehalten werden müssen. Sind für Consumer und Producer unterschiedliche Teams verantwortlich, entfällt zudem die kommunikative Abstimmung während der Testerstellung.
  • Die Contract-Tests testen eine isolierte produktive Schnittstelle, im besten Fall in der gleichen Version, in der sie produktiv eingesetzt wird.
  • Bei diesem Vorgehen werden isolierte Schnittstellen-Aufrufe erzeugt. Es erfolgt kein Test über nebenläufig erzeugte Artefakte wie es bei anderen Ansätzen der Fall ist. Insbesondere bei Projekten, die über wenig oder gar keine Tests auf Systemebene verfügen, kann die integrativere Art der Contract-Tests ein zusätzliches Sicherheitsnetz bieten.

Nachteile

  • Gegenüber kompletten Mocking-Ansätzen wie z.B. Pact ist die Testausführung integrativer. Es können Fehler auf Infrastruktur-Ebene auftreten, die eine ganze CI-Pipeline zum Stillstand bringen können. Ports können belegt sein, Server nicht sauber heruntergefahren werden, usw. Durch konsequente Containerisierung der im Contract Test beteiligten Kollaborateure (Producer, Mountebank) können derartige Probleme jedoch auf ein Minimum reduziert werden.
  • Da bei diesem Vorgehen Schnittstellenaufrufe erzeugt werden, ist die Testausführung langsamer als bei anderen Ansätzen. Vor allem das Herunterladen des Producers in der aktuell produktiven Version und das Hochfahren des Producers verlangsamen die Testausführung. Somit kann auch der Schnitt des Producers Einfluss auf die Ausführungszeit haben. Die Ausführungszeit eines einzelnen Tests lag jedoch erfahrungsgemäß im (Milli-) Sekundenbereich.
  • Liegen Consumer und Producer in der Codeverantwortung unterschiedlicher Teams, müssen sich beide Parteien auf dieses Vorgehen einigen. Denn auch auf Producer-Seite müssen für dieses Vorgehen Eingriffe in die Codebasis vorgenommen werden.

Links: