Consumer-Driven Contracts mit Pact – Teil 2: Implementierung mit Java

01.12.2020

Ein Beitrag von Sarah Mildenberger und Tobias Eberle.
Im diesem zweiten Beitrag der Blogpostreihe zu Consumer-Driven Contracts geht es um die konkrete Implementierung von Contracts mit Pact und Java. Für die Grundlagen von Contracts und Pact empfehlen wir zunächst Teil 1 der Blogpostreihe

Um Pact an einem Beispiel zeigen zu können, stellen wir uns einen kleinen, sehr vereinfachten Webshop vor. Dieser besteht aus den Backend-Services Inventory, Payment  und Shipment  und einem Frontend, dem Webshop. Der Webshop ist hier der Consumer, die Backend-Services sind die Provider.

Webshop-Übersicht

Wir betrachten für dieses Beispiel den Inventory-Service und den Webshop. Der Inventory-Service stellt die Schnittstelle /items/{itemId} zur Verfügung, durch die der Webshop die Informationen für ein bestimmtes Produkt abrufen kann. Er erwartet eine Antwort in der folgenden Struktur:

{
  "itemId": "phone_1",
  "name": "iPhone X",
  "description": "an ordinary phone",
  "price": 600.19,
  "currency": "EURO",
  "stock": 42,
  "imagePath": "/iPhoneXImage.png",
  "madeIn": "China",
  "availableSince": „2019-10-02“
}

Mögliche Breaking API-Changes

Nun kann es passieren, dass der Provider seine API ändert, ohne den Consumer darüber zu informieren. Beispiele hierfür sind:

  • Der Provider (Inventory-Service) entfernt ein Feld von der Response, das von Consumern benötigt wird, z.B. das Feld „description“
    Konsequenz: Der Consumer (Webshop) benötigt diese Description, jetzt fehlt sie ihm jedoch
  • Provider ändert die URL eines Endpoints oder entfernt einen Endpoint, den der Consumer verwendet, z.B. /items/{itemId}/product/{productId}
    Konsequenz: Der Consumer weiß nicht mehr, wo er die Daten überhaupt anfragen kann
  • Provider ändert das Datumsformat eines Felds, z.B. „availableSince“: yyyy-MM-dddd.MM.yyyy
    Konsequenz: Der Consumer kann das Datum nicht mehr parsen

Genau diese möglichen Fehlerquellen werden wir jetzt mit Hilfe von Contracts mit Pact beseitigen.

Definition von Contracts auf der Seite des Consumers (Webshop)

Auf Seite des Consumers werden die Contracts definiert. Dies geschieht im Rahmen von Unit Tests. Es werden hierfür zwei Dinge benötigt:

  1. Die Definition eines Pacts
  2. Eine Test-Methode zur Verfikation des Pacts. Wenn dieser Test erfolgreich durchläuft, wird automatisch ein Pact-File erstellt und im Pact-Verzeichnis (z.B. target/pacts) abgelegt.

1. Definition eines Pacts

Wir definieren zunächst den erwarteten Body  mit Hilfe der Pact-DSL. Hier haben wir die Möglichkeit, genaue Erwartungswerte anzugeben (z.B. über „stringValue“: es wird genau dieser String erwartet) oder nur den erwarteten Datentyp (z.B. „stringType“: es wird ein String erwartet). Außerdem können wir ein erwartetes Datumsformat angeben („date“).

Anschließend bauen wir den Pact. Mit „given“ können wir einen Provider-State angeben, auf den wir später nocheinmal zurückkommen. Außerdem geben wir den Pfad, Header, den erwarteten Response-Status und den erwarteten Body an.

@Pact(provider = "inventory-service", consumer = "webshop-service")
public RequestResponsePact pactGetItemDetailsPhone3(final PactDslWithProvider builder)
      throws JsonProcessingException {

   // Values returned to consumer by mock provider are random if not specified by ...Value, e.g. stringValue
   final DslPart json = new PactDslJsonBody() //
         .stringType("itemId") // any String
         .stringValue("name", "Samsung Phone") // this specific String
         .stringType("description") //
         .numberType("price") // any Number
         .stringType("currency") //
         .integerType("stock") //
         .stringType("imagePath") //
         .stringType("madeIn") //
         .date("availableSince", "yyyy-MM-dd");

   final Map<String, String> requestHeaders = new HashMap<>();
   requestHeaders.put(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);

   return builder //
         .given("Item phone_3 exists") // "given" can be used to prepare provider state
         .uponReceiving("A GET request to /items/phone_3/") // "uponReceiving" is the description of the contract
         .path("/items/phone_3/") //
         .method("GET") //
         .headers(requestHeaders) //
         .willRespondWith() //
         .status(200) //
         .body(json) //
         .toPact();
}

2. Verifikation des Pacts

Es wird außerdem eine Test-Methode benötigt, um den Pact zu verifizieren. Über die Annotation @PactVerification wird die Methode angegeben, die den zu verifizierenden Pact definiert.

Während der Ausführung der Unit-Tests wird  vom Pact-Framework die in dem Pact definierte Anfrage an einen Mock-Provider gestellt. Es können dann entsprechende Assertions durchgeführt werden. Ist der Test erfolgreich, wird das Pact-File erstellt.

@Test  
@PactVerification(fragment = "pactGetItemDetailsPhone3")
public void verifyPactGetItemDetailsPhone3() {

   // arrange
   final HttpEntity requestHeaders = buildRequestHeadersAcceptApplicationJson();

   // act
   final ResponseEntity<Item> response = new RestTemplate()
      .exchange(inventoryProviderMock.getUrl() + "/items/phone_3/", HttpMethod.GET, requestHeaders, Item.class);

   // assert
   assertEquals(response.getStatusCode().value(), 200);
   assertEquals("Samsung Phone", response.getBody().getName());
}

Austausch des Pact-Files zwischen Consumer und Provider

Das Pact-File muss nun dem Provider übergeben werden, damit dieser prüfen kann, ob die von ihm bereitgestellte API den definierten Contracts entspricht. Dies kann händisch geschehen. In unserem Beispiel wird jedoch ein Pact-Broker eingesetzt, auf den der Consumer die Pact-Files hochlädt. Hierfür wird der Befehl mvn pact:publish verwendet. Der Provider holt die Pacts vom Broker ab. So müssen die Pacts nicht mehr manuell ausgetauscht werden und der gesamte Prozess kann automatisiert ausgeführt werden.

Ein weiterer Vorteil des Pact-Brokers ist, dass der Provider die Ergebnisse seiner Verifikation wieder an ihn zurückspielen kann. Der Consumer kann von dort dann abrufen, ob die APIs des Providers den Contracts entspricht, und ob er selbst deployed werden kann.

Verifikation der API des Providers mit Pact

Die Verifikation auf Provider-Seite läuft großteils automatisiert ab. Wir stellen eine Testklasse bereit, um Grundeinstellungen festzulegen. Hier wird auch über die Annotation @PactBroker angegeben, unter welcher Adresse der Pact-Broker zu erreichen ist, von dem die Pact-Files abgerufen werden können.

Die Tests selbst werden auf Grundlage des Pact-Files generiert und automatisiert während der Unit-Test-Phase ausgeführt. Hierbei wird vom Pact-Framework ein Consumer simuliert, der die in dem Pact definierten Anfragen an den Provider sendet und prüft, ob die Antworten den erwarteten Antworten ensprechen. Erst wenn alle Tests erfolgreich sind ist sicher, dass die API des Providers den Erwartungen des Consumers entspricht und erst dann darf er deployed werden. So können keine Breaking API-Changes deployed werden.

@RunWith(PactRunner.class)
@Provider("inventory-service")
@PactBroker(host = "localhost", port = "80")
public class InventoryProviderTest {

   @TestTarget
   public final MockMvcTarget target = new MockMvcTarget();

   private static final Logger LOG = LoggerFactory.getLogger(InventoryProviderTest.class);
   private final ItemsApiController controller = new ItemsApiController();

   @Before
   public void before() {
      MockitoAnnotations.initMocks(this);
      target.setControllers(controller);
      System.setProperty("pact.verifier.publishResults", "true");
   }
   
   // ==========
   // Phone 1
   // ==========
   @State("Item phone_1 exists")
   public void statePhone1Exists() {
      // Phone 1 exists, do nothing
   }

   // ==========
   // Phone 2
   // ==========
   @State("Item phone_2 is created")
   public void statePhone2IsCreated() {

      // instead of creating a real Item, might use a mock service, test db or similar
      controller.createPhone2();
   }

   // ==========
   // Phone 3
   // ==========
   @State(value = "Item phone_3 exists", action = StateChangeAction.SETUP)
   public void createPhone3() {

      controller.createPhone3();

      final List<Item> allItems = controller.getItems().getBody();
      boolean phoneCreated = false;
      for (final Item item : allItems) {
         if (item.getItemId().equals("phone_3")) {
            phoneCreated = true;
         }
      }

      assertTrue(phoneCreated);
      LOG.info("Created phone 3");
   }

   @State(value = "Item phone_3 exists", action = StateChangeAction.TEARDOWN)
   public void deletePhone3() {

      controller.deletePhone3();

      final List<Item> allItems = controller.getItems().getBody();
      boolean phoneDeleted = true;
      for (final Item item : allItems) {
         if (item.getItemId().equals("phone_3")) {
            phoneDeleted = false;
         }
      }

      assertTrue(phoneDeleted);
      LOG.info("Deleted phone 3");
   }
}

Provider States

Vorhin haben wir gesehen, dass wir in den Pacts sogenannte „Provider States“ definieren können („given“ bei der Erstellung der Pacts). Diese dienen dazu, dass der Provider für die Tests bestimmte Zustände „einrichten“ kann: Beispielsweise kann ein Item angelegt werden, mit dem der Test durchgeführt werden kann, oder es können Services gemockt werden, die ein Mock-Item zurückgeben. Für Tests müssen dann nicht Produktivdaten verwendet werden, sondern es kann die benötigte Test-Umgebung geschaffen werden.

Hier im Beispiel sehen wir, dass für den State „Item phone_1 exists“ nichts getan wird. Das Item Phone 1 existiert bereits. Für den State „Item phone_2 is created“ wird das Phone 2 angelegt.
Für den State „Item phone_3 exists“ wird das Phone 3 angelegt (action = StateChangeAction.SETUP) und nach dem Test wieder gelöscht (action = StateChangeAction.TEARDOWN).

Noch Fragen?

Wir freuen uns über Kommentare! :)

 

 

Mehr zu Java Programmierung erfahren

Zurück zur Übersicht

5 Kommentare zu “Consumer-Driven Contracts mit Pact – Teil 2: Implementierung mit Java

  1. Hallo und danke für den Kommentar!
    Beide Methoden werden vom Pact-Framework benötigt, damit das Pact-File definiert und anschließend erstellt wird.
    Auf Basis der in der Methode ‚pactGetItemDetailsPhone3‘ konfigurierten Regeln wird später das Pact-File erstellt. Hier ist zum einen definiert, wie eine Response vom Provider aussehen muss, damit der Client mit ihr umgehen kann, also welche Werte in welchem Datentyp (im Falle des Datums sogar in welchem Format) vorhanden sein müssen. Zum anderen wird der „Rahmen“ für den Vertrag definiert, z.B. was im Provider vorbereitet sein muss, um den Pact zu verifizieren. In unserem Fall müsste im Provider-Test z.B. ein Item mit der Id „phone_3“ vorhanden sein, wie weiter unten im Artikel unter „Provider States“ zu sehen ist. Außerdem wird spezifiziert, unter welcher URL die Response erwartet wird und mit welchem Response-Status.
    Erst durch die Ausführung der @Test-Methode ‚verifyPactGetItemDetailsPhone3‘ wird das Pact-File erstellt, das wir anschließend weiterverwenden können, um auch den Provider zu testen. Die Assertions in der Test-Methode sind im Beitrag beispielhaft.
    Viele Grüße
    Sarah

  2. Ist das Aufsetzen des Vertrags zwingend in der Methode pactGetItemDetailsPhone3 notwendig? Denn im Grunde sind alle Informationen über den Pact auch in der @Test Methode gegeben. Könnte man also die pactGetItemDetailsPhone3 Methode umgehen? und wenn nicht, warum wird diese Methode benötigt?

  3. Hallo, vielen Dank für den Kommentar!

    „Consumer-Driven Contracts“ bedeutet ja, dass der Consumer dem Provider mitteilt, welche Schnittstellen er von diesem erwartet. Daher wird hier zuerst der Consumer (z.B. die UI) gebaut und im Zuge der Unit-Tests wird der Contract erstellt, der dem Provider übermittelt wird. Der Provider wiederum prüft in seinen Unit-Tests, ob er dem Contract gerecht wird. Falls dies nicht der Fall ist, muss der Provider erst angepasst und neu deployed werden. Über den Pact-Broker ist es bspw. möglich, das Ergebnis der Provider-Verifizierung an den Consumer zurückzugeben, sodass auch dieser weiß, dass der Provider in einer für ihn ansprechbaren Version verfügbar ist.
    Wenn also der Consumer eine Änderung an der Schnittstelle haben möchte, ist das erstmal kein Problem, weil er dem Provider die geänderten Anforderungen durch den Contract mitteilt.

    Wenn der Provider die Schnittstelle ändern möchte oder muss, ist das technisch nicht durch einen Consumer-Driven Contract abgedeckt. Hier kommt man nicht drumrum, sich abzustimmen ;) Ich würde mich hier mit dem Provider zusammensetzen und diese Änderung absprechen, sodass sie in die Contracts mit aufgenommen werden kann. Anschließend läuft wieder der normale Prozess der Verifizierung im Rahmen der Unit-Tests ab.
    Das zeigt auch eine wichtige Grenze bei Consumer-DrivenContracts auf: wir kennen nicht jeden Provider „persönlich“ bzw. haben nicht die Möglichkeit, bei jedem Provider Forderungen zu stellen. Möchten wir z.B. eine API von Google ansprechen, können wir das schlecht Consumer-Driven tun.

    Die Integrationstests finden erst statt, wenn die Contract-Verifizierungen abgeschlossen und Consumer und Provider in der richtigen Version deployed sind.

    Viele Grüße
    Sarah

  4. Was passiert, wenn man aber explizit ein Versionsupdate machen will, wo die API sich ändert … das funktioniert ja nicht, weil die ganzen Consumer DrivenContract tests fehlschlagen werden. D.h. die Consumers müssten erstmal ihren Consumer Driven Contract Test updaten, aber das UI wird normalerweise auch End-To-End getestet mit dem Backend … wie sollen, die das auf Ihrerseite machen, wenn das neue Backend noch nicht bereitsteht (das es nicht gebaut werden kann)? Das hieße ja, das UI müsste schon gebaut sein ohne Deployed zu sein … und kann auch nicht mit einem Integrationstest gegen ein Backened getestet werden

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

*Pflichtfelder

*