Testing Java Spring Boot Microservices

dans java, microservices, spring, tests par Nadia Humbert-Labeaumaz et Renaud Humbert-Labeaumaz

Tests are an essential part of our codebase. At the very least, they minimize the risk of regression when we modify our code. There are several types of tests and each has a specific role: unit tests, integration tests, component tests, contract tests and end-to-end tests. It is crucial to understand the role of each type of test in order to leverage their potential.

The goal of this article is to describe a strategy to use them in order to test Java Spring Boot microservices. For every type of test, we will try to explain its role, its scope as well as tooling we like to use.

Anatomy of a Microservice

First of all, we will set up a common vocabulary to make this article as clear as possible.

A standard microservice is composed of:

Types of Tests

Unit Tests

Unit tests allow to test a unit (generally a method) in isolation. They are very cost-effective: easy to set up and very fast. Thus, they can give a fast feedback about the state of the application to quickly spot bugs or regressions. It is then advised to test every edge case and relevant combination with unit tests. As a bonus, they can validate a design: if the code is really difficult to test, the design is probably bad.

In a microservice, like in any other codebase, it is crucial to unit test domain / service classes and every other class that contains logic.

The tooling we prefer to write unit tests is Junit (to run the tests), AssertJ (to write assertions) and Mockito (to mock external dependencies).

Integration Tests

Integration tests are used to test the proper integration of the different bricks of the application. They are sometimes hard to set up and have to be carefully chosen. The idea is not to test all possible interactions but to choose relevant ones. The feedback of these tests is less fast than with unit tests because they are slower to execute. It is important to note that writing too many integration tests for the same interaction can be counter-productive. Indeed, the build time will be increased without any added value.

In a microservice, integration tests can be written for:

Spring Boot provides a very good tooling to write integration tests. A typical integration test with Spring Boot looks like this:

1
2
3
4
5
6
7
8
9
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserGatewayIntTest {

    @Autowired
    private UserGateway userGateway;

    // ...
}

The test must use the SpringRunner and be annotated with @SpringBootTest. It is then possible to inject a bean using @Autowired and to mock one using @MockBean. In an integration test, the database should be embedded (H2 database is a good candidate) in order for the tests to be executable anywhere. For the same reason, external HTTP resources can be mocked using WireMock and an SMTP server with SubEthatSMTP.

In order to be able to mock external microservices, the port must be fixed. In production, microservices will register themselves to a registry and an URL will be dynamically assigned to them. If Ribbon is used with Spring Cloud, it is possible to fix the URL in tests, by adding a property to the test application.yml (here, the external microservice name is user):

1
2
3
user:
  ribbon:
    listOfServers: localhost:9999

Component Tests

Component tests allow to test complete use cases from end to end. They are often expensive especially in terms of setup and execution time. Thus, thought needs to be given to define their scope. Nevertheless, they are required in order to check and document the overall behaviour of the application or the microservice.

In the context of microservices, these tests are very cost-effective. Indeed, they can be quite easy to set up because the already existing external API of the microservice can often be used directly without needing additional elements (like a fake server for instance). Moreover, the scope of a microservice is generally limited and can be tested exhaustively in isolation.

Component tests should be concise and easy to understand (see How to Write Robust Component Tests). The goal is to test the behaviour of the microservice by writing a nominal case and very few edge cases. We noticed that writing the specification before implementing the feature can lead to very simple component tests. Moreover, it is a good practice to write the component tests in collaboration with the different project stakeholders in order to cover the feature in a very efficient way.

In component tests, an embedded database can also be used. Moreover, it is possible to mock HTTP and AMQP clients: this is not the place to test the integration with external resources (see Setup a Circuit Breaker with Hystrix, Feign Client and Spring Boot).

An example of tooling we can use to write component tests is Gherkin (to write the specifications) with Cucumber (to run the tests).

In order to perform requests on the HTTP API of the microservice and make assertions on the response, MockMvc can be used.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@WebAppConfiguration
@SpringBootTest
@ContextConfiguration(classes = AppConfig.class)
public class StepDefs {

    @Autowired private WebApplicationContext context;
    private MockMvc mockMvc;
    private ResultActions actions;

    @Before public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }

    @When("^I get \"([^\"]*)\" on the application$")
    public void iGetOnTheApplication(String uri) throws Throwable {
        actions = mockMvc.perform(get(uri));
    }

    @Then("^I get a Response with the status code (\\d+)$")
    public void iGetAResponseWithTheStatusCode(int statusCode) throws Throwable {
        actions.andExpect(status().is(statusCode));
    }
}

In order to inject AMQP messages, the channel used by Spring Cloud Stream can also be injected directly into the test.

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
// AMQP listener code
@EnableBinding(MyStream.Process.class)
public class MyStream {

    @StreamListener("myChannel")
    public void handleRevision(Message<MyMessageDTO> message) {
      // handle message
    }

    public interface Process {
        @Input("myChannel") SubscribableChannel process();
    }
}

// Cucumber step definition
public class StepDefs {

    @Autowired
    private MyStream.Process myChannel;

    @When("^I publish an event with the following data:$")
    public void iPublishAnEventWithTheFollowingData(String payload) {
        myChannel.process().send(new GenericMessage<>(payload));
    }
}

Finally, it may be important to fix the time to make tests more robust (see Controlling the Time in Java)

1
2
3
4
5
6
7
8
9
10
public class StepDefs {

    @Autowired @MockBean private ClockProvider clockProvider;

    @Given("^The time is \"([^\"]*)\"$")
    public void theTimeIs(String datetime) throws Throwable {
        ZonedDateTime date = ZonedDateTime.parse(datetime);
        when(clockProvider.get()).thenReturn(Clock.fixed(date.toInstant(), date.getZone()));
    }
}

Contract Tests

The goal of contract tests is to automatically verify that the provider of a service and its consumers speak the same language. These tests do not aim to verify the behaviour of the components but simply their contracts. They are particularly useful for microservices since almost all their value lies in their interactions. It is crucial to guarantee that no provider breaks the contract used by its consumers.

The general idea is that consumers write tests that define the initial state of the provider, the request sent by the consumer and the expected response. The provider must supply a server in the required state. The contract will automatically be verified against this server. This implies the following:

It is important to note that contract tests should stick to the real needs of the consumer. If a field is not used by a consumer, it should not be tested in the contract test. Then, the provider is free to update or delete every field that is not used by any consumer and we are sure that if tests fail, it is for a good reason.

The tool we like to use to write and execute contract tests is Pact. It is a very mature product that has plugins for a lot of languages (JVM, Ruby, .NET, Javascript, Go, Python, etc.). Moreover, it is well integrated with Spring MVC thanks to the DiUS pact-jvm-provider-spring plugin. During the execution of the consumer tests, contracts (called pacts) are generated in JSON format. They can be shared with the provider using a service called the Pact Broker.

This is an example of a consumer test written with the DiUS pact-jvm-consumer-junit plugin:

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
@Test
public void should_send_booking_request_and_get_rejection_response() throws AddressException {

    RequestResponsePact pact = ConsumerPactBuilder
            .consumer("front-office")
            .hasPactWith("booking")
            .given("The hotel 1234 has no vacancy")
            .uponReceiving("a request to book a room")
            .path("/api/book")
            .method("POST")
            .body("{" +
                    "\"hotelId\": 1234, " +
                    "\"from\": \"2017-09-01\", " +
                    "\"to\": \"2017-09-16\"" +
            "}")
            .willRespondWith()
            .status(200)
            .body("{ \"errors\" : [ \"There is no room available for this booking request.\" ] }")
            .toPact();

    PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
        BookingResponse response = bookingClient.send(aBookingRequest());
        assertThat(response.getErrors()).contains("There is no room available for this booking request.");
    });

    assertEquals(PactVerificationResult.Ok.INSTANCE, result);
}

On the server side:

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
@RunWith(RestPactRunner.class)
@Provider("booking")
@Consumer("front-office")
@PactBroker(
        host = "${PACT_BROKER_HOST}", port = "${PACT_BROKER_PORT}", protocol = "${PACT_BROKER_PROTOCOL}",
        authentication = @PactBrokerAuth(username = "${PACT_BROKER_USER}", password = "${PACT_BROKER_PASSWORD}")
)
public class BookingContractTest {

    @Mock private BookingService bookingService;
    @InjectMocks private BookingResource bookingResource;

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

    @Before
    public void setUp() throws MessagingException, IOException {
        initMocks(this);
        target.setControllers(bookingResource);
    }

    @State("The hotel 1234 has no vacancy")
    public void should_have_no_vacancy() {
        when(bookingService.book(eq(1234L), any(), any())).thenReturn(BookingResult.NO_VACANCY);
    }
}

End to End Tests

End to end tests need the whole platform to be up and running to run entire business use cases across multiple microservices. They are very expensive and slow to run. These tests can be performed manually on a dedicated platform but have to be chosen with great care to maximize their benefits.

To Sum up

Conclusion

Automatic tests are very important in the software development industry. A good testing strategy can help write more relevant, robust and maintainable tests. This article describes an example of strategy to test Java Spring Boot microservices.

Nadia Humbert-Labeaumaz

Nadia est coordinatrice d'équipes et de projets. Elle accompagne les entreprises à apporter en continu de la valeur métier et de la qualité au travail produit.

Renaud Humbert-Labeaumaz

Renaud est développeur Java. Il pratique le TDD, le refactoring et les revues de code au quotidien pour produire du code propre et maintenable à forte valeur métier.

Contactez-nous

Crafties réalise des audits de code, des formations, des accompagnements et des développements. Vous pouvez nous contacter à contact@crafties.fr pour tout besoin.