Effizientes API-Testing mit Hoverfly

09.10.2024Ricky Elfner
DevOps API Microservices CI/CD DevOps Testing Developer Experience

Banner

Hoverfly ist ein Tool, das speziell für die Simulation und Kontrolle von HTTP- und HTTPS-Interaktionen entwickelt wurde. Es bietet Entwicklern die Möglichkeit, Abhängigkeiten von externen Diensten während des Testens zu minimieren, indem man realistische Simulationen dieser Dienste bereitstellt. In diesem Techup gucken wir die grundlegenden Themen an und zeige auf, wie man Hoverfly in einem Java-Projekt einsetzen kann inklusive GitHub-Actions.

Simulieren von HTTP-Anfragen

Der zentrale Bestandteil der Bibliothek ist die Hoverfly-Klasse, die für die Abstraktion und Steuerung einer Hoverfly-Instanz sorgt. Hier ist ein typischer Ablauf:

1
2
3
4
try (Hoverfly hoverfly = new Hoverfly(configs(), SIMULATE)) {
    hoverfly.start();
    // ...
}

Verfügbare Betriebsmodi von Hoverfly

Bevor wir uns die ein Praxisbeispiel Step by Step anschauen, möchte ich kurz erklären, welche unterschiedlichen Betriebsmodi zur Verfügung stehen um unterschiedliche Testanforderungen abzudecken und die API-Simulation optimal zu nutzen.

Simulationsmodus (Simulating)

Im Simulationsmodus verhält sich Hoverfly so, als wäre es der echte Dienst und beantwortet Anfragen entsprechend. Das ist besonders praktisch, um die Abhängigkeit von externen Diensten während des Testens zu vermeiden. Später zeigen wir noch ein konkretes Beispiel, wie man diesen Modus einrichtet.

1
2
3
4
try (Hoverfly hoverfly = new Hoverfly(configs(), SIMULATE)) {
    hoverfly.start();
    // ...
}

Proxy-Modus (Spy-Modus)

Der SPY-Modus von Hoverfly ermöglicht es, HTTP-Anfragen an eine reale API weiterzuleiten, wenn keine passende Simulation für diese Anfragen vorhanden ist. Dabei fungiert Hoverfly als Proxy, der den Netzwerkverkehr überwacht und aufzeichnet. Wenn eine Anfrage ausgeführt wird, prüft Hoverfly zunächst, ob es eine Simulation für diese Anfrage gibt. Falls ja, wird die gemockte Version zurückgeliefert. Falls nein, wird die Anfrage an die echte API weitergeleitet.

1
2
3
4
try (Hoverfly hoverfly = new Hoverfly(configs(), SPY)) {
    hoverfly.start();   
    // ...
}

[!NOTE]

Genaues Beispiel bei uns im Techup-Repo

Aufzeichnen von API-Anfragen (Capture-Modus)

Hoverfly kann auch im Capture-Modus betrieben werden, wobei der Netzwerkverkehr zu einer realen API aufgezeichnet und die erhaltenen Responses gespeichert werden können. Hierfür muss die Methode exportSimulation definiert werden. Die gespeicherten JSONs können dann in einem weiteren Schritt sogar im Simulationsmodus genutzt werden. Mit diesem Modus lässt sich somit der gewünschte Request sehr genau nachbauen.

1
2
3
4
5
try(Hoverfly hoverfly = new Hoverfly(localConfigs(), CAPTURE)) {
    hoverfly.start();
    // ...
    hoverfly.exportSimulation(Paths.get("some-path/simulation.json"));
}

[!NOTE]

Genaues Beispiel bei uns im Techup-Repo

Unterschiede erkennen: Der Diff-Modus

Im Diff-Modus erkennt Hoverfly die Unterschiede zwischen einer Simulation und den tatsächlichen Anfragen und Antworten. Hoverfly erstellt dann einen Diff-Bericht, den man später überprüfen kann.

1
2
3
4
5
try(Hoverfly hoverfly = new Hoverfly(configs(), DIFF)) {
    hoverfly.start();
    // ...
    hoverfly.assertThatNoDiffIsReported(false);
}

Erweiterte Funktionen von Hoverfly

Neben den grundlegenden Betriebsmodi bietet Hoverfly eine Reihe erweiterter Funktionen, die es ermöglichen, komplexere Szenarien zu simulieren und die Flexibilität der API-Tests deutlich zu erhöhen.

API-Endpunkte definieren: Nutzung der Domain Specific Language (DSL)

Hoverfly bietet eine DSL, um Anfragen und Antworten in Java statt als JSON zu definieren. Dies ermöglicht eine fluente und hierarchische Definition von Endpunkten.

1
2
3
4
5
6
7
8
SimulationSource.dsl(
    service("www.my-test.com")
        .post("/api/bookings")
  			.body("{\"flightId\": \"1\"}")
        .willReturn(created("http://localhost/api/bookings/1"))
        .get("/api/bookings/1")
        .willReturn(success("{\"bookingId\":\"1\"}", "application/json"))
);

Simulieren von Netzwerklatenzzeiten

Es ist möglich, Netzwerklatenzzeiten zu simulieren, entweder global für alle Anfragen oder spezifisch für bestimmte HTTP-Methoden.

1
2
3
4
SimulationSource.dsl(
    service("www.slow-service.com")
        .andDelay(3, TimeUnit.SECONDS).forAll()
);

Anfragen präzise matchen: Request Field Matchers

Hoverfly bietet Matchers, um komplexe Anfragen zu definieren, wie z.B. Wildcards, Regex oder JSON-Path-Matching.

1
2
3
4
5
6
SimulationSource.dsl(
    service(matches("www.*-test.com"))
        .get(startsWith("/api/bookings/"))
        .queryParam("page", any())
        .willReturn(success(json(booking)))
);

Zustandsbasierte Simulationen (Stateful Simulation)

Hoverfly unterstützt zustandsbasierte Simulationen, bei denen ein Dienst unterschiedliche Antworten basierend auf dem aktuellen Zustand zurückgibt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
SimulationSource.dsl(
    service("www.service-with-state.com")
        .get("/api/bookings/1")
        .willReturn(success("{\"bookingId\":\"1\"}", "application/json"))
        .delete("/api/bookings/1")
        .willReturn(success().andSetState("Booking", "Deleted"))
        .get("/api/bookings/1")
        .withState("Booking", "Deleted")
        .willReturn(notFound())
);

Verifizieren von Anfragen: Die Verification-Funktion

Mit der Verifikationsfunktion kann überprüft werden, ob bestimmte Anfragen an die externen Dienstendpunkte gestellt wurden.

1
2
3
4
5
hoverfly.verify(
    service(matches("*.flight.*"))
        .get("/api/bookings")
        .anyQueryParams(), times(1)
);

Hands-On – REST-Client in Java mit Hoverfly testen

In diesem Teil werden wir Schritt für Schritt durchgehen, wie man einen REST-Client in Java implementiert mit unterschiedlichen API-Endpunkten implementiert. Dieses Beispiel verwendet den MicroProfile Rest Client und zeigt, wie man Hoverfly nutzen kann, um diese Interaktion zu simulieren und zu testen.

Installation der benötigten Abhängigkeiten

In unserem Fall haben wir eine Quarkus Applikation mit maven. Somit fügen wir einfach folgende Dependencies in unsere pom.xml ein:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<dependency>
    <groupId>io.specto</groupId>
    <artifactId>hoverfly-java</artifactId>
    <version>0.18.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.specto</groupId>
    <artifactId>hoverfly-java-junit5</artifactId>
    <version>0.17.1</version>
    <scope>test</scope>
</dependency>

Erstellen und Konfigurieren des REST-Clients

Zunächste definieren wir eine Schnittstelle mit dem Namen BnovaRestClient. Mit der Anotation @Path definieren wird den Base-Path für diesen Endpunkt, in diesem Fall /techhub.

Für die Regestrierung des Clients, wird die Annotation @RegisterRestClient verwendet. Diese Annotation ermöglicht es, die Schnittstelle später innerhalb unseres Controller zu injecten und zu verwenden.

Die erste Methode die wir anlegen ist getById. Dabei setzen wir die @GET Annotation, was bedeutet, dass es ein HTTP-GET-Request ist. Die @Produces-Annotation zeigt an, dass die Methode eine JSON-Response zurückgeben soll. Diese Methode nimmt dabei einen QueryParam id entgegen, um das Techup-Objekt basierend auf der übergebenen ID abzurufen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.bnova;

import jakarta.ws.rs.*;

import java.util.List;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;


@Path("/techhub")
@RegisterRestClient
public interface BnovaRestClient
{
	@GET
	@Produces(MediaType.APPLICATION_JSON)
	Techup getById(@QueryParam("id") String id);
}

Um den REST-Client in unserer Anwendung zuverwenden, benötigen wir einen Controller, der von der Applikation aufgerufen werden kann. Der Controller wird quasi als Vermittler zwischen den Anfragen, die an unsere Anwendung gestellt werden, und dem REST-Client, den wir zuvor definiert haben genutzt.

Hier ist der Code für den TechupController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.bnova;

import jakarta.ws.rs.*;

import java.util.List;
import org.eclipse.microprofile.rest.client.inject.RestClient;


@Path("/techhub")
public class TechupController
{

	@RestClient
	BnovaRestClient restClient;

	@GET
	@Path("/{id}")
	@Produces(MediaType.APPLICATION_JSON)
	public Techup getTechupById(@PathParam("id") String id)
	{
		return restClient.getById(id);
	}
}

Der TechupController ist ein REST-Controller, der Anfragen an den Pfad /techhub verarbeitet, was über die Annotation @Path definiert wird.

Über die Annotation @RestClient wird der BnovaRestClient in den Controller injected. Dadurch kann der Controller, den REST-Client verwenden, um externe Anfragen zu stellen.

Die Methode getTechupById ist mit @GET annotiert, was sie als HTTP-GET-Anfrage kennzeichnet. Der spezifische Pfad für diese Methode ist /{id}, wobei id ein Pfadparameter ist. Die @Produces-Annotation gibt an, dass die Methode eine JSON-Antwort liefert.

Einrichten und Durchführen von Tests

Nachdem wir nun den REST-Client und den Controller implementiert haben, ist der nächste Schritt das Erstellen von Tests notwendig, um sicherzustellen, dass auch alles wie erwartet funktioniert.

Die Testklasse erstellen: TechupTest

Zuerst erstellen wir die Testklasse TechupTest. Dafür benötigen wir zwei Annotation einmal @QuarkusTest mit der die Klasse als Quarkus-Test gekennzeichnet wird, damit das Quarkus-Test-Framework die Umgebung initialisiert. Die Annotation @QuarkusTestResource(value = HoverflyResource.class) verknüpft den Test mit der HoverflyResource. Diese Ressource werden wir im nächsten Schritt auch als Test-Ressource anlegen und dabei Hoverfly als Simulation der API-Aufrufe nutzen.

Für alle folgenden Tests wird die Bibliothek RestAssured verwendet, um eine HTTP-GET-Anfrage an den entsprechenden Endpunkt zusenden. In dem Test testGetById handelt es sich dabei um den Pfad /techhub/{id}. Mit der Methode given() wird der Pfadparameter id auf 1 gesetzt. Anschließend wird mit when() und get("/techhub/{id}") die Anfrage ausgeführt. In der anschließenden then()-Kette wird überprüft, ob der HTTP-Statuscode der Response 200 (OK) ist, ob der Content-Type der Antwort application/json ist und ob die Felder der Antwort den erwarteten Werten entsprechen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.bnova;

import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;

import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;


@QuarkusTest
@QuarkusTestResource(value = HoverflyResource.class)
public class TechupTest
{

	@Test
	void testGetById()
	{
		given()
				.pathParam("id", "1")
				.when()
				.get("/techhub/{id}")
				.then()
				.statusCode(200)
				.contentType("application/json")
				.body("id", is("1"))
				.body("slug", is("tech-slug"))
				.body("name", is("Tech Name"))
				.body("content", is("Tech Content"))
				.body("description", is("Tech Description"))
				.body("author", is("Tech Author"));
	}
}

Hoverfly-Resource für Mocking anlegen

Nun müssen wir auch noch die HoverflyResource anlegen, auf welche wir bereits in der Testklasse verwiesen haben. Diese Klasse dient als unsere Mock-Umgebung für unseren BnovaRestClient.

Dabei implementiert die Klasse HoverflyResource das Interface QuarkusTestResourceLifecycleManager, welche Methoden zur Verwaltung des Lebenszyklus der Test-Ressource bereitstellt.

In der start()-Methode wird als erstes eine Hoverfly-Instanz mit der Constanten SIMULATE im Simulationsmodus gestartet damit wir simulieren können, wie die API-Antworten aussehen sollen. Als erster Parameter wird jedoch die Methode localConfigs() aufgerufen, die eine Konfiguration für die lokale Ausführung von Hoverfly zurückgibt. Diese Konfiguration enthält verschiedene standard Einstellungen. Das einzige was wir hier anpassen ist die die Ziel Url, welche Hoverfly simulieren soll. Dabei haben wir die Konstante SERVICE_URL genutzt, welche “my-hoverfly-service” als Wert hat. Dadurch können wir sicher sein, dass nur Requests zu dieser spezifischen URL von Hoverfly abgefangen und simuliert werden. Damit dies auch später über die CLI funktioniert und auch innerhalb der Github-Actions setzten wir diesen Wert ebenfalls über die application.properties:

1
2
quarkus.rest-client."com.bnova.BnovaRestClient".url=https://b-nova.com/home/content/
%test.quarkus.rest-client."com.bnova.BnovaRestClient".url=http://my-hoverfly-service

Um den eigentlichen Simulationsmodus zu starten, wird die Methode .simulate() verwendet. Als Parameter können dann die einzelnen Mocks definiert werden. Die kann man am besten mit der Domain Soecific Language machen, damit es klar strukturiert und auch lesbar ist. Hierzu muss man einfach die Methode .dsl() nutzen.

In dem folgenden Beispiel wird spezifiziert, dass die Simulation auf GET-Anfragen an den Pfad /techhub reagieren soll, wenn es einen Parameter id mit dem Wert 1 gibt. Wenn dies der Fall ist soll der Inhalt aus dem JSON-File example_get_by_id.json zurück geben werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.bnova;

import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
import io.specto.hoverfly.junit.core.Hoverfly;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Map;

import static io.specto.hoverfly.junit.*;

public class HoverflyResource implements QuarkusTestResourceLifecycleManager
{
    private static final String APPLICATION_JSON = "application/json";
    private static final String TECHHUB = "/techhub";
    private static final String SERVICE_URL = "my-hoverfly-service";
    private static final String BASE_PATH = "src/test/resources/__files/";

    private Hoverfly hoverfly;

    @Override
    public Map<String, String> start()
    {
       hoverfly = new Hoverfly(localConfigs().destination(SERVICE_URL), SIMULATE);
       hoverfly.start();
       hoverfly.simulate(
             dsl(
                   service(SERVICE_URL)
                         .get(TECHHUB)
                         .queryParam("id", "1")
                         .willReturn(success(
                               readJsonFile("example_get_by_id.json"),
                               APPLICATION_JSON))

             ));
       return null;
    }

    private String readJsonFile(String path)
    {
       try
       {
          return Files.readString(Paths.get(BASE_PATH + path));
       }
       catch (IOException e)
       {
          throw new RuntimeException("Failed to read JSON file: " + path, e);
       }
    }

    @Override
    public void stop()
    {
       if (hoverfly != null)
       {
          hoverfly.close();
       }
    }
}

Bei der Response handelt es sich um ein Techup Objekt im JSON-Format:

1
2
3
4
5
6
7
8
9
//example_get_by_id.json
{
  "id": "1",
  "slug": "tech-slug",
  "name": "Tech Name",
  "content": "Tech Content",
  "description": "Tech Description",
  "author": "Tech Author"
}

Hoverfly in GitHub Actions integrieren

Wollen wir beispielsweise nun unsere Applikation mittels GitHub Action builden, ist ein mvn clean install notwendig. Dabei werden auch alle Test ausgeführt. Und auch hier muss natürlich unsere Hoverfly Mock laufen. Und hier kommt die grosse Überraschung, es ist nichts weiter zu tun als ein den Build zu starten. Hier ist meine Beispiel GitHub Action:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: CI

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  build-jdk21:
    name: "JDK 21 Build"
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21
      - name: Build
        run: mvn clean install

Sobald ich nun auf main pushe, startet die Action. Dabei kann man im Log sehen, dass diese erfolgreich ausgeführt werden:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.bnova.TechupTest
2024-07-29 11:27:59,303 INFO  [io.spe.hov.jun.cor.TempFileManager] (pool-3-thread-1) Selecting the following binary based on the current operating system: hoverfly_linux_amd64
2024-07-29 11:27:59,305 INFO  [io.spe.hov.jun.cor.TempFileManager] (pool-3-thread-1) Storing binary in temporary directory /tmp/hoverfly.13453686857838846364/hoverfly_linux_amd64
2024-07-29 11:27:59,394 INFO  [io.spe.hov.jun.cor.Hoverfly] (pool-3-thread-1) Executing binary at /tmp/hoverfly.13453686857838846364/hoverfly_linux_amd64
2024-07-29 11:27:59,411 INFO  [hoverfly] (Thread-37) Default proxy port has been overwritten port=39171
2024-07-29 11:27:59,412 INFO  [hoverfly] (Thread-37) Default admin port has been overwritten port=45975
2024-07-29 11:27:59,412 INFO  [hoverfly] (Thread-37) Using memory backend 
2024-07-29 11:27:59,412 INFO  [hoverfly] (Thread-37) Proxy prepared... Destination=. Mode=simulate ProxyPort=39171
2024-07-29 11:27:59,413 INFO  [hoverfly] (Thread-37) current proxy configuration destination=. mode=simulate port=39171
2024-07-29 11:27:59,413 INFO  [hoverfly] (Thread-37) Admin interface is starting... AdminPort=45975
2024-07-29 11:27:59,413 INFO  [hoverfly] (Thread-37) serving proxy 
2024-07-29 11:27:59,440 INFO  [io.spe.hov.jun.cor.Hoverfly] (pool-3-thread-1) A local Hoverfly with version v1.9.1 is ready
2024-07-29 11:27:59,447 INFO  [hoverfly] (Thread-37) Mode has been changed mode=simulate
2024-07-29 11:28:00,406 WARN  [hoverfly] (Thread-37) Stopping listener 
2024-07-29 11:28:00,406 INFO  [hoverfly] (Thread-37) sending done signal 
2024-07-29 11:28:00,407 INFO  [hoverfly] (Thread-37) Proxy prepared... Destination=my-hoverfly-service Mode=simulate ProxyPort=39171
2024-07-29 11:28:00,407 INFO  [hoverfly] (Thread-37) current proxy configuration destination=my-hoverfly-service mode=simulate port=39171
2024-07-29 11:28:00,407 INFO  [hoverfly] (Thread-37) serving proxy 
2024-07-29 11:28:00,410 INFO  [io.spe.hov.jun.cor.ProxyConfigurer] (pool-3-thread-1) Setting proxy host to localhost
2024-07-29 11:28:00,410 INFO  [io.spe.hov.jun.cor.ProxyConfigurer] (pool-3-thread-1) Setting proxy proxyPort to 39171
2024-07-29 11:28:00,438 INFO  [io.spe.hov.jun.cor.Hoverfly] (pool-3-thread-1) Importing simulation data to Hoverfly
2024-07-29 11:28:00,463 INFO  [hoverfly] (Thread-37) payloads imported failed=0 successful=6 total=6
2024-07-29 11:28:00,969 INFO  [io.quarkus] (main) hoverfly-example 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.10.2) started in 4.316s. Listening on: http://localhost:8081
2024-07-29 11:28:00,977 INFO  [io.quarkus] (main) Profile test activated. 
2024-07-29 11:28:00,977 INFO  [io.quarkus] (main) Installed features: [cdi, rest, rest-client, rest-client-jackson, rest-jackson, smallrye-context-propagation, vertx]
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.414 s -- in com.bnova.TechupTest
2024-07-29 11:28:03,150 INFO  [io.quarkus] (main) hoverfly-example stopped in 0.072s
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] 

Fazit

Hoverfly ist ein äußerst vielseitiges und leistungsstarkes Tool, das speziell für die Simulation von API-Interaktionen in Microservices-Umgebungen entwickelt wurde. Mit seinen verschiedenen Betriebsmodi bietet es eine umfassende Lösung, um Abhängigkeiten von externen Diensten während des Testens zu eliminieren, reale API-Anfragen aufzuzeichnen und zu simulieren sowie Unterschiede zwischen Simulationen und tatsächlichen Antworten zu erkennen. Dank der nahtlosen Integration in Java-Projekte und CI/CD-Pipelines ermöglicht Hoverfly eine kontinuierliche und automatisierte Überprüfung von Anwendungen, was die Zuverlässigkeit und Effizienz im Entwicklungsprozess deutlich erhöht. Entwickler, die realistische und flexible API-Simulationen benötigen, finden in Hoverfly ein passendes Werkzeug.

Auch in unterschiedlichen Kundenprojekten ist Hoverfly bereits erfolgreich integriert!

[!TIP]

Die gesamten Beispiele sind auch in unserem Techhub Repository verfügbar

Ricky Elfner

Ricky Elfner – Denker, Überlebenskünstler, Gadget-Sammler. Dabei ist er immer auf der Suche nach neuen Innovationen, sowie Tech News, um immer über aktuelle Themen schreiben zu können.