Efficient API Testing with Hoverfly

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

Banner

Hoverfly is a tool specifically designed for simulating and controlling HTTP and HTTPS interactions. It allows developers to minimize dependencies on external services during testing by providing realistic simulations of these services. In this techup, we will look at the basic topics and show how to use Hoverfly in a Java project including GitHub actions.

Simulating HTTP requests

The central component of the library is the Hoverfly class, which is responsible for abstracting and controlling a Hoverfly instance. Here is a typical flow:

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

Available operating modes of Hoverfly

Before we look at a practical example step by step, I would like to briefly explain the different operating modes available to cover different testing requirements and make optimal use of API simulation.

Simulation mode (Simulating)

In simulation mode, Hoverfly acts as if it were the real service and responds to requests accordingly. This is particularly useful for avoiding dependencies on external services during testing. Later we will show a concrete example of how to set up this mode.

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

Proxy mode (Spy mode)

Hoverfly’s SPY mode allows HTTP requests to be forwarded to a real API if no matching simulation exists for those requests. Hoverfly acts as a proxy that monitors and records network traffic. When a request is made, Hoverfly first checks to see if there is a simulation for that request. If so, the mocked version is returned. If not, the request is forwarded to the real API.

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

[!NOTE]

You can find a detailed example in our Techup repository

Recording API requests (Capture mode)

Hoverfly can also be operated in capture mode, where network traffic to a real API can be recorded and the received responses can be saved. For this, the exportSimulation method must be defined. The saved JSONs can then even be used in simulation mode in a further step. With this mode, the desired request can be recreated very precisely.

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

[!NOTE]

You can find a detailed example in our Techup repository

Recognizing differences: The Diff mode

In Diff mode, Hoverfly detects the differences between a simulation and the actual requests and responses. Hoverfly then creates a diff report that you can review later.

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

Advanced features of Hoverfly

In addition to the basic operating modes, Hoverfly offers a number of advanced features that allow you to simulate more complex scenarios and significantly increase the flexibility of your API tests.

Defining API endpoints: Using the Domain Specific Language (DSL)

Hoverfly provides a DSL to define requests and responses in Java instead of JSON. This allows for a fluent and hierarchical definition of endpoints.

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"))
);

Simulating network latency

It is possible to simulate network latency, either globally for all requests or specifically for certain HTTP methods.

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

Matching requests precisely: Request Field Matchers

Hoverfly provides matchers to define complex requests, such as wildcards, regex, or 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)))
);

State-based simulations (Stateful Simulation)

Hoverfly supports stateful simulations where a service returns different responses based on the current state.

 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())
);

Verifying requests: The Verification function

The verification function can be used to check whether certain requests were made to the external service endpoints.

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

Hands-On - Testing a REST client in Java with Hoverfly

In this part, we will walk through step-by-step how to implement a REST client in Java with different API endpoints. This example uses the MicroProfile Rest Client and shows how to use Hoverfly to simulate and test this interaction.

Installing the required dependencies

In our case, we have a Quarkus application with maven. So we simply add the following dependencies to our pom.xml:

 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>

Creating and configuring the REST client

First, we define an interface called BnovaRestClient. With the @Path annotation, we define the base path for this endpoint, in this case /techhub.

For registering the client, the @RegisterRestClient annotation is used. This annotation allows the interface to be injected and used later within our controller.

The first method we create is getById. We set the @GET annotation, which means it is an HTTP GET request. The @Produces annotation indicates that the method should return a JSON response. This method takes a query parameter id to retrieve the Techup object based on the passed ID.

 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);
}

To use the REST client in our application, we need a controller that can be called by the application. The controller is used as a mediator between the requests made to our application and the REST client we defined earlier.

Here is the code for the 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);
	}
}

The TechupController is a REST controller that handles requests to the /techhub path, which is defined by the @Path annotation.

The @RestClient annotation injects the BnovaRestClient into the controller. This allows the controller to use the REST client to make external requests.

The getTechupById method is annotated with @GET, marking it as an HTTP GET request. The specific path for this method is /{id}, where id is a path parameter. The @Produces annotation indicates that the method returns a JSON response.

Setting up and running tests

Now that we have implemented the REST client and the controller, the next step is to create tests to ensure that everything is working as expected.

Creating the test class: TechupTest

First, we create the test class TechupTest. For this, we need two annotations: @QuarkusTest, which marks the class as a Quarkus test so that the Quarkus test framework initializes the environment, and @QuarkusTestResource(value = HoverflyResource.class), which links the test to the HoverflyResource. We will create this resource in the next step as a test resource and use Hoverfly to simulate the API calls.

For all subsequent tests, the RestAssured library is used to send an HTTP GET request to the corresponding endpoint. In the testGetById test, this is the /techhub/{id} path. The given() method sets the path parameter id to 1. Then the request is executed with when() and get("/techhub/{id}"). The subsequent then() chain checks whether the HTTP status code of the response is 200 (OK), whether the content type of the response is application/json and whether the fields of the response correspond to the expected values.

 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"));
	}
}

Creating a Hoverfly resource for mocking

Now we also need to create the HoverflyResource that we already referenced in the test class. This class serves as our mock environment for our BnovaRestClient.

The class HoverflyResource implements the interface QuarkusTestResourceLifecycleManager, which provides methods for managing the lifecycle of the test resource.

In the start() method, a Hoverfly instance is first started with the constant SIMULATE in simulation mode so that we can simulate what the API responses should look like. However, the first parameter calls the localConfigs() method, which returns a configuration for running Hoverfly locally. This configuration contains various default settings. The only thing we adjust here is the target URL that Hoverfly should simulate. We used the constant SERVICE_URL, which has “my-hoverfly-service” as its value. This way we can be sure that only requests to this specific URL are intercepted and simulated by Hoverfly. To make this work later via the CLI and also within the Github actions, we also set this value via the 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

To start the actual simulation mode, the .simulate() method is used. The individual mocks can then be defined as parameters. The best way to do this is to use the Domain Specific Language so that it is clearly structured and readable. To do this, you simply need to use the .dsl() method.

The following example specifies that the simulation should respond to GET requests to the /techhub path if there is a parameter id with the value 1. If this is the case, the content from the JSON file example_get_by_id.json should be returned.

 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();
       }
    }
}

The response is a Techup object in 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"
}

Integrating Hoverfly into GitHub Actions

If, for example, we now want to build our application using GitHub Action, a mvn clean install is required. This will also run all tests. And of course, our Hoverfly mock must also run here. And here comes the big surprise, there is nothing more to do than start a build. Here is my example 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

As soon as I push to main, the action starts. You can see in the log that it was executed successfully:

 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] 

Conclusion

Hoverfly is an extremely versatile and powerful tool specifically designed for simulating API interactions in microservice environments. With its various operating modes, it offers a comprehensive solution to eliminate dependencies on external services during testing, record and simulate real API requests, and detect differences between simulations and actual responses. Thanks to its seamless integration into Java projects and CI/CD pipelines, Hoverfly enables continuous and automated testing of applications, significantly increasing reliability and efficiency in the development process. Developers who need realistic and flexible API simulations will find Hoverfly to be a suitable tool.

Hoverfly has already been successfully integrated into various customer projects!

[!TIP]

All examples are also available in our Techhub Repository

This techup has been translated automatically by Gemini

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.