Testing Microservices: Tools and Frameworks

We face some key challenges around microservices architecture testing. The selection of the right tools is one of the elements that help us deal with the issues related to those challenges. First, let's identify the most important elements involved in the process of microservices testing:

Let's discuss some interesting frameworks helping that may help you in testing microservice-based architecture.

Components Tests With Hoverfly

Hoverfly's simulation mode may be especially useful for building component tests. During component tests, we verify the whole microservice without communication over a network with other microservices or external datastores. The following picture shows how such a test is performed for our sample microservice.


Hoverfly provides simple DSL for creating simulations, and a JUnit integration for using it within JUnit tests. It may be orchestrated via JUnit's @Rule. We are simulating two services and then overriding Ribbon properties to resolve the address of these services by client name. We should also disable communication with Eureka discovery by disabling registration after application boot or fetching a list of services for the Ribbon client. Hoverfly simulates responses for PUT and GET methods exposed with the passenger-management and driver-management microservices. The Controller is the main component that implements business logic in our application. It stores data using an in-memory repository component and communicates with other microservices through @FeignClient interfaces. By testing the three methods implemented by the controller, we are testing the whole business logic implemented in the trip-management service.

@SpringBootTest(properties = {
 "eureka.client.enabled=false",
 "ribbon.eureka.enable=false",
 "passenger-management.ribbon.listOfServers=passenger-management",
 "driver-management.ribbon.listOfServers=driver-management"
})
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class TripComponentTests {

 ObjectMapper mapper = new ObjectMapper();

 @Autowired
 MockMvc mockMvc;

 @ClassRule
 public static HoverflyRule rule = HoverflyRule.inSimulationMode(SimulationSource.dsl(
  HoverflyDsl.service("passenger-management:80")
  .get(HoverflyMatchers.startsWith("/passengers/login/"))
  .willReturn(ResponseCreators.success(HttpBodyConverter.jsonWithSingleQuotes("{'id':1,'name':'John Walker'}")))
  .put(HoverflyMatchers.startsWith("/passengers")).anyBody()
  .willReturn(ResponseCreators.success(HttpBodyConverter.jsonWithSingleQuotes("{'id':1,'name':'John Walker'}"))),
  HoverflyDsl.service("driver-management:80")
  .get(HoverflyMatchers.startsWith("/drivers/"))
  .willReturn(ResponseCreators.success(HttpBodyConverter.jsonWithSingleQuotes("{'id':1,'name':'David Smith','currentLocationX': 15,'currentLocationY':25}")))
  .put(HoverflyMatchers.startsWith("/drivers")).anyBody()
  .willReturn(ResponseCreators.success(HttpBodyConverter.jsonWithSingleQuotes("{'id':1,'name':'David Smith','currentLocationX': 15,'currentLocationY':25}")))
 )).printSimulationData();

 @Test
 public void test1CreateNewTrip() throws Exception {
  TripInput ti = new TripInput("test", 10, 20, "walker");
  mockMvc.perform(MockMvcRequestBuilders.post("/trips")
    .contentType(MediaType.APPLICATION_JSON_UTF8)
    .content(mapper.writeValueAsString(ti)))
   .andExpect(MockMvcResultMatchers.status().isOk())
   .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.any(Integer.class)))
   .andExpect(MockMvcResultMatchers.jsonPath("$.status", Matchers.is("NEW")))
   .andExpect(MockMvcResultMatchers.jsonPath("$.driverId", Matchers.any(Integer.class)));
 }

 @Test
 public void test2CancelTrip() throws Exception {
  mockMvc.perform(MockMvcRequestBuilders.put("/trips/cancel/1")
    .contentType(MediaType.APPLICATION_JSON_UTF8)
    .content(mapper.writeValueAsString(new Trip())))
   .andExpect(MockMvcResultMatchers.status().isOk())
   .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.any(Integer.class)))
   .andExpect(MockMvcResultMatchers.jsonPath("$.status", Matchers.is("IN_PROGRESS")))
   .andExpect(MockMvcResultMatchers.jsonPath("$.driverId", Matchers.any(Integer.class)));
 }

 @Test
 public void test3PayTrip() throws Exception {
  mockMvc.perform(MockMvcRequestBuilders.put("/trips/payment/1")
    .contentType(MediaType.APPLICATION_JSON_UTF8)
    .content(mapper.writeValueAsString(new Trip())))
   .andExpect(MockMvcResultMatchers.status().isOk())
   .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.any(Integer.class)))
   .andExpect(MockMvcResultMatchers.jsonPath("$.status", Matchers.is("PAYED")));
 }

}

The tests above verify only positive scenarios. What about testing unexpected behavior, like network delays or server errors? With Hoverfly, we can easily simulate such a behavior and define some negative scenarios. In the following fragment of code, I have defined three scenarios. In the first of them, the target service has been delayed for two seconds. In order to simulate timeout on the client side, I had to change the default readTimeout for Ribbon load balancer and disable the Hystrix circuit breaker for the Feign client. The second test simulates an HTTP 500 response status from the passenger-management service. The last scenario assumes an empty response from the method responsible for searching the nearest driver.

@SpringBootTest(properties = {
 "eureka.client.enabled=false",
 "ribbon.eureka.enable=false",
 "passenger-management.ribbon.listOfServers=passenger-management",
 "driver-management.ribbon.listOfServers=driver-management",
 "feign.hystrix.enabled=false",
 "ribbon.ReadTimeout=500"
})
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
public class TripNegativeComponentTests {

 private ObjectMapper mapper = new ObjectMapper();
 @Autowired
 private MockMvc mockMvc;

 @ClassRule
 public static HoverflyRule rule = HoverflyRule.inSimulationMode(SimulationSource.dsl(
  HoverflyDsl.service("passenger-management:80")
  .get("/passengers/login/test1")
  .willReturn(ResponseCreators.success(HttpBodyConverter.jsonWithSingleQuotes("{'id':1,'name':'John Smith'}")).withDelay(2000, TimeUnit.MILLISECONDS))
  .get("/passengers/login/test2")
  .willReturn(ResponseCreators.success(HttpBodyConverter.jsonWithSingleQuotes("{'id':1,'name':'John Smith'}")))
  .get("/passengers/login/test3")
  .willReturn(ResponseCreators.serverError()),
  HoverflyDsl.service("driver-management:80")
  .get(HoverflyMatchers.startsWith("/drivers/"))
  .willReturn(ResponseCreators.success().body("{}"))
 ));

 @Test
 public void testCreateTripWithTimeout() throws Exception {
  mockMvc.perform(MockMvcRequestBuilders.post("/trips").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(new TripInput("test", 15, 25, "test1"))))
   .andExpect(MockMvcResultMatchers.status().isOk())
   .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.nullValue()))
   .andExpect(MockMvcResultMatchers.jsonPath("$.status", Matchers.is("REJECTED")));
 }

 @Test
 public void testCreateTripWithError() throws Exception {
  mockMvc.perform(MockMvcRequestBuilders.post("/trips").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(new TripInput("test", 15, 25, "test3"))))
   .andExpect(MockMvcResultMatchers.status().isOk())
   .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.nullValue()))
   .andExpect(MockMvcResultMatchers.jsonPath("$.status", Matchers.is("REJECTED")));
 }

 @Test
 public void testCreateTripWithNoDrivers() throws Exception {
  mockMvc.perform(MockMvcRequestBuilders.post("/trips").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(new TripInput("test", 15, 25, "test2"))))
   .andExpect(MockMvcResultMatchers.status().isOk())
   .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.nullValue()))
   .andExpect(MockMvcResultMatchers.jsonPath("$.status", Matchers.is("REJECTED")));
 }

}

All the timeouts and errors in communication with external microservices are handled by the bean annotated with @ControllerAdvice. In such cases, the trip-management microservice should not return a server error response, but a 200 OK with a JSON response containing a field status equal to REJECTED.

@ControllerAdvice
public class TripControllerErrorHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({RetryableException.class, FeignException.class})
    protected ResponseEntity handleFeignTimeout(RuntimeException ex, WebRequest request) {
        Trip trip = new Trip();
        trip.setStatus(TripStatus.REJECTED);
        return handleExceptionInternal(ex, trip, null, HttpStatus.OK, request);
    }

}

Contract Tests With Pact

The next type of test strategy usually implemented for microservices-based architecture is consumer-driven contract testing. In fact, there are some tools especially dedicated for this type of tests. One of them is Pact. Contract testing is a way to ensure that services can communicate with each other without implementing integration tests. A contract is signed between two sides of communication: consumer and provider. Pact assumes that contract code is generated and published on the consumer side, and then verified by the provider.

Pact provides a tool that can store and share the contracts between consumers and providers. It is called Pact Broker. It exposes a simple RESTful API for publishing and retrieving pacts, and an embedded web dashboard for navigating the API. We can easily run Pact Broker on the local machine using its Docker image.

We will begin by running Pact Broker. Pact Broker requires a running instance of PostgreSQL, so first, we have to launch it using the Docker image, and then link our broker container with that container.

docker run -d --name postgres -p 5432:5432 -e POSTGRES_USER=oauth -e POSTGRES_PASSWORD=oauth123 -e POSTGRES_DB=oauth postgres
docker run -d --name pact-broker --link postgres:postgres -e PACT_BROKER_DATABASE_USERNAME=oauth -e PACT_BROKER_DATABASE_PASSWORD=oauth123 -e PACT_BROKER_DATABASE_HOST=postgres -e PACT_BROKER_DATABASE_NAME=oauth -p 9080:80 dius/pact-broker

The next step is to implement contract tests on the consumer side. We will use the JVM implementation of the Pact library for that. It provides the PactProviderRuleMk2 object responsible for creating stubs of the provider service. We should annotate it with JUnit's @Rule. Ribbon will forward all requests to passenger-management to the stub address, in that case, localhost:8180. Pact JVM supports annotations and provides DSL for building test scenarios. The test method responsible for generating contract data should be annotated with @Pact. It is important to set the state and provider fields because the generated contract will be verified on the provider side using these names. Generated pacts are verified inside the same test class by the methods annotated with @PactVerification. Field fragment points to the name of the method responsible for generating pact inside the same test class. The contract is tested using PassengerManagementClient@FeignClient.

@RunWith(SpringRunner.class)
@SpringBootTest(properties = {
 "driver-management.ribbon.listOfServers=localhost:8190",
 "passenger-management.ribbon.listOfServers=localhost:8180",
 "ribbon.eureka.enabled=false",
 "eureka.client.enabled=false",
 "ribbon.ReadTimeout=5000"
})
public class PassengerManagementContractTests {

 @Rule
 public PactProviderRuleMk2 stubProvider = new PactProviderRuleMk2("passengerManagementProvider", "localhost", 8180, this);
 @Autowired
 private PassengerManagementClient passengerManagementClient;

 @Pact(state = "get-passenger", provider = "passengerManagementProvider", consumer = "passengerManagementClient")
 public RequestResponsePact callGetPassenger(PactDslWithProvider builder) {
  DslPart body = new PactDslJsonBody().integerType("id").stringType("name").numberType("balance").close();
  return builder.given("get-passenger").uponReceiving("test-get-passenger")
   .path("/passengers/login/test").method("GET").willRespondWith().status(200).body(body).toPact();
 }

 @Pact(state = "update-passenger", provider = "passengerManagementProvider", consumer = "passengerManagementClient")
 public RequestResponsePact callUpdatePassenger(PactDslWithProvider builder) {
  return builder.given("update-passenger").uponReceiving("test-update-passenger")
   .path("/passengers").method("PUT").bodyWithSingleQuotes("{'id':1,'amount':1000}", "application/json").willRespondWith().status(200)
   .bodyWithSingleQuotes("{'id':1,'name':'Adam Smith','balance':5000}", "application/json").toPact();
 }

 @Test
 @PactVerification(fragment = "callGetPassenger")
 public void verifyGetPassengerPact() {
  Passenger passenger = passengerManagementClient.getPassenger("test");
  Assert.assertNotNull(passenger);
  Assert.assertNotNull(passenger.getId());
 }

 @Test
 @PactVerification(fragment = "callUpdatePassenger")
 public void verifyUpdatePassengerPact() {
  Passenger passenger = passengerManagementClient.updatePassenger(new PassengerInput(1 L, 1000));
  Assert.assertNotNull(passenger);
  Assert.assertNotNull(passenger.getId());
 }

}

Just running the tests is not enough. We also have to publish pacts generated during tests to Pact Broker. In order to achieve this, we have to include the following Maven plugin in our pom.xml and  execute the command mvn clean install pact:publish.

<plugin>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-maven_2.12</artifactId>
<version>3.5.21</version>
<configuration>
<pactBrokerUrl>http://192.168.99.100:9080</pactBrokerUrl>
</configuration>
</plugin>

Pact provides support for Spring on the provider side. Thanks to that, we may use MockMvc controllers or inject properties from application.yml into the test class. Here's the dependency declaration that has to be included in our pom.xml

<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-spring_2.12</artifactId>
<version>3.5.21</version>
<scope>test</scope>
</dependency>

Now the contract is being verified on the provider side. We need to pass the provider name inside the @Provider annotation and name of states for every verification test inside @State. These values have been in the tests on the consumer side inside @Pact annotation (fields state and provider).

@RunWith(SpringRestPactRunner.class)
@Provider("passengerManagementProvider")
@PactBroker
public class PassengerControllerContractTests {

 @InjectMocks
 private PassengerController controller = new PassengerController();
 @Mock
 private PassengerRepository repository;
 @TestTarget
 public final MockMvcTarget target = new MockMvcTarget();

 @Before
 public void before() {
  MockitoAnnotations.initMocks(this);
  target.setControllers(controller);
 }

 @State("get-passenger")
 public void testGetPassenger() {
  target.setRunTimes(3);
  Mockito.when(repository.findByLogin(Mockito.anyString()))
   .thenReturn(new Passenger(1 L, "Adam Smith", "test", 4000))
   .thenReturn(new Passenger(3 L, "Tom Hamilton", "hamilton", 400000))
   .thenReturn(new Passenger(5 L, "John Scott", "scott", 222));
 }

 @State("update-passenger")
 public void testUpdatePassenger() {
  target.setRunTimes(1);
  Passenger passenger = new Passenger(1 L, "Adam Smith", "test", 4000);
  Mockito.when(repository.findById(1 L)).thenReturn(passenger);
  Mockito.when(repository.update(Mockito.any(Passenger.class)))
   .thenReturn(new Passenger(1 L, "Adam Smith", "test", 5000));
 }
}

The Pact Broker host and port are injected from the application.yml file.


pactbroker:
  host: "192.168.99.100"
  port: "8090"

Performance Tests With Gatling

An important step of testing microservices before deploying them to production is performance testing. An interesting tool in this area is Gatling. It is a highly capable load testing tool written in Scala. That means that we also have to use Scala DSL in order to build test scenarios. Let's begin by adding the required library to the pom.xml file.

<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>2.3.1</version>
</dependency>

Now we may proceed to the test. In the scenario above, we are testing two endpoints exposed by trip-management: POST /trips and PUT /trips/payment/${tripId}. In fact, this scenario verifies the whole functionality of our sample system, where we are setting up a trip and then pay for it after the finish.

Every test class using Gatling needs to extend the Simulation class. We are defining our scenario using the scenario method and then setting its name. We may define multiple executions in a single scenario. After every execution of the POST /trips method test, it saves the generated id returned by the service. Then, it inserts that id into the URL used for calling the method PUT /trips/payment/${tripId}. Every single test expects a response with a 200 OK status.

Gatling provides two interesting features worth mentioning. You can see how they are used in the following performance test. The first of them is feeder. It is used for polling records and injecting their content into the test. The feed rPassengers selects one of five defined logins randomly. The final test result may be verified using the Assertions API. It is responsible for verifying whether global statistics like response time or the number of failed requests match expectations for a whole simulation. In the scenario below, the criterium is max response time, which needs to be lower than 100 milliseconds.

class CreateAndPayTripPerformanceTest extends Simulation {

 val rPassengers = Iterator.continually(Map("passenger" -> List("walker", "smith", "hamilton", "scott", "holmes").lift(Random.nextInt(5)).get))

 val scn = scenario("CreateAndPayTrip").feed(rPassengers).repeat(100, "n") {
  exec(http("CreateTrip-API")
   .post("http://localhost:8090/trips")
   .header("Content-Type", "application/json")
   .body(StringBody(""
    "{"
    destination ":"
    test$ {
     n
    }
    ","
    locationX ":${n},"
    locationY ":${n},"
    username ":"
    $ {
     passenger
    }
    "}"
    ""))
   .check(status.is(200), jsonPath("$.id").saveAs("tripId"))
  ).exec(http("PayTrip-API")
   .put("http://localhost:8090/trips/payment/${tripId}")
   .header("Content-Type", "application/json")
   .check(status.is(200))
  )
 }

 setUp(scn.inject(atOnceUsers(20))).maxDuration(FiniteDuration.apply(5, TimeUnit.MINUTES))
  .assertions(global.responseTime.max.lt(100))

}

In order to run Gatling performance tests, you need to include the following Maven plugin in your pom.xml. You may run a single scenario or  multiple scenarios. After including the plugin, you only need to execute the command mvn clean gatling:test.

<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>2.2.4</version>
<configuration>
<simulationClass>pl.piomin.performance.tests.CreateAndPayTripPerformanceTest</simulationClass>
</configuration>
</plugin>

Here are some diagrams illustrating the result of performance tests for our microservice. Because maximum response time is greater than set in the assertion (100ms), the test has failed.

And...

Summary

The right selection of tools is not the most important element of microservices testing, however, the right tools can help you face the key challenges related to it. Hoverfly allows us to create full component tests that verify if your microservice is able to handle delays or errors from downstream services. Pact helps you to organize the team by sharing and verifying contracts between independently developed microservices. Finally, Gatling can help you implement load tests for selected scenarios in order to verify the end-to-end performance of your system.

The source code used as a demo for this article is available on GitHub.

 

 

 

 

Top