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. Therefore, it is crucial to understand the role of each type of test to leverage its potential.
This article describes a strategy to use them to test Java Spring Boot microservices. It presents each type of test’s role, scope, and tooling.
Anatomy of a Microservice
A standard microservice is composed of the following:
- Resources: HTTP controllers or AMQP listeners serving as the entry point of the microservice.
- Services/domain: Classes containing the business logic.
- Repositories: Classes exposing an API to access storage (like a database).
- Clients: HTTP clients or AMQP producers communicating with external resources.
- Gateways: Classes serving as interfaces between domain services and clients by handling HTTP or AMQP-related tasks and providing a clean API to the domain.
Types of Tests
Unit Tests
Unit tests allow testing a unit (generally a method) in isolation and are very cost-effective: easy to set up and fast. As a result, they can provide immediate feedback about the application’s state to spot bugs or regressions. As a bonus, they help validate a design: if the code is difficult to test, the design is probably bad.
As a result, it’s a good practice to test every edge case and suitable combination with unit tests. In a microservice context, like in any other codebase, it’s essential to unit test domain/service classes and every class containing logic.
The preferred tooling 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. However, they’re sometimes hard to set up and have to be carefully chosen: the idea isn’t to test all possible interactions but to choose relevant ones. And their feedback is less fast than the one with unit tests because they’re slower to execute.
It’s important to note that writing too many integration tests for the same interaction can be counter-productive. Indeed, the build time will increase without getting any added value.
In a microservice, it’s relevant to write integration tests for:
- Repositories – when the query is more complex than just a
findById
. - Services – in case of doubt on the interaction between the service and repository (JPA or transaction issues, for instance).
- HTTP clients.
- Gateways.
Spring Boot provides great tooling to write integration tests. A typical integration test with Spring Boot looks like this:
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserGatewayIntTest {
@Autowired
private UserGateway userGateway;
// ...
}
The test must use the SpringRunner
and be annotated with @SpringBootTest
. It’s then possible to inject a bean using @Autowired
and mock one using @MockBean
.
Also, the database should be embedded (the H2 database is a good candidate) for the integration tests to be executable anywhere. For the same reason, external HTTP resources can be mocked using WireMock and an SMTP server with SubEthatSMTP.
To mock external microservices, it’s necessary to fix the port. In production, microservices will register themselves to a registry and dynamically get an URL assigned. In tests, you can fix the URL by adding a property to the test application.yml
if Ribbon is used with Spring Cloud (here, the external microservice name is user
):
user:
ribbon:
listOfServers: localhost:9999
Component Tests
Component tests allow testing complete use cases from end to end. However, they’re often expensive in terms of setup and execution time. Thus, their scope must be carefully defined, as with integration tests. Nevertheless, these tests are necessary to check and document the application’s or the microservice’s overall behaviour.
In the context of microservices, component tests are very cost-effective. Indeed, they can be easy to set up because it’s often possible to use the microservice’s existing external API directly without needing additional elements (like a fake server). 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 microservice’s behaviour by writing a nominal case and very few edge cases. Note that writing the specification before implementing the feature can lead to straightforward component tests. Moreover, writing the component tests in collaboration with the different project stakeholders is good practice to cover the feature effectively.
An example of tooling we can use to write component tests is Gherkin (to write the specifications) with Cucumber (to run the tests).
In component tests, you can use an embedded database. Moreover, it’s 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).
To perform requests on the HTTP API of the microservice and make assertions on the response, MockMvc can be used.
@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));
}
}
To inject AMQP messages, the channel used by Spring Cloud Stream can also be injected directly into the test.
// 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)
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 don’t aim to verify the behaviour of the components but simply their contracts. Moreover, they’re handy for microservices since almost all their value lies in their interactions: it’s crucial to guarantee that no provider breaks the contract used by its consumers.
The general idea is that consumers write tests that define the provider’s initial state, 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:
- on the consumer side: contract tests are written using the HTTP client. Given a provider state, assertions are made on the HTTP response.
- on the provider side: only the HTTP resource should be instantiated. All its dependencies should be mocked to provide the required state.
It’s important to note that contract tests should stick to the consumer’s real needs. In other words, if a consumer doesn’t use a field, it shouldn’t be tested in the contract test. And, then, the provider is free to update or delete every field that’s not used by any consumer, and we’re thus sure that if tests fail, it’s for a good reason.
You can use Pact to write and execute contract tests. It’s a very mature product with plugins for many languages (JVM, Ruby, .NET, Javascript, Go, Python, etc.). Moreover, it’s 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:
@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:
@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. Unfortunately, they’re costly and slow to run. These tests can be performed manually or automatically on a dedicated platform but have to be chosen strategically to maximize their benefits.
To Sum Up
Conclusion
Automatic tests are essential in the software development industry. A good testing strategy can help write more relevant, robust and maintainable tests. This article describes an example of a strategy to test Java Spring Boot microservices.