Tests are an essential part of any codebase. At a minimum, they help prevent regressions as the code evolves. But not all tests are created equal — unit, integration, component, contract, and end-to-end tests each serve a distinct purpose.
This article outlines a practical testing strategy for Java Spring Boot microservices, covering the role, scope, and tooling for each test type to help you make the most of your test suite.
Anatomy of a Microservice
A typical microservice is composed of:
- Resources: HTTP controllers or AMQP listeners that serve as entry points.
- Services/domain: Classes containing business logic.
- Repositories: Classes that expose an API to access storage (such as a database).
- Clients: HTTP clients or AMQP producers that communicate with external resources.
- Gateways: Classes that interface between domain services and clients, handling HTTP or AMQP concerns while exposing a clean API to the domain.
Types of Tests
Unit Tests
Unit tests validate individual units of logic (typically methods) in isolation. They’re fast, lightweight, and cost-effective (i.e., easy to set up and use). As such, they help catch regressions and bugs early. They also serve as a design feedback mechanism: if the code is difficult to test, the design is likely flawed or in need of refinement.
It’s a good practice to test all edge cases and meaningful input combinations with unit tests. In a microservice architecture, as in any other codebase, you should unit-test domain/service classes and any logic-heavy components.
The following tools are effective in this context: Junit to run the tests, AssertJ to write assertions, and Mockito (to mock external dependencies).
Integration Tests
Integration tests verify that the various components of the application interact correctly with one another. These tests can be more complex to set up, so they should be selected strategically, focusing on the most relevant and meaningful interactions. Their feedback is also less immediate, as they tend to run more slowly than unit tests.
Avoid writing too many integration tests for the same behaviour — it slows down builds without adding value.
In the context of microservices, I recommend writing integration tests for the following components:
- Repositories, when queries are more complex than a simple
findById
. - Services, when interactions with repositories are complex or sensitive (e.g., JPA or transaction issues).
- HTTP clients.
- Gateways.
Spring Boot offers excellent tooling for integration tests. Here’s what a typical test setup looks like:
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserGatewayIntTest {
@Autowired
private UserGateway userGateway;
// ...
}
The test has to use the SpringRunner
and be annotated with @SpringBootTest
. It’s then possible to inject a bean using @Autowired
or mock one using @MockBean
. Additionally, the database should be embedded so that integration tests can run anywhere — the H2 database is a solid choice here. Similarly, external HTTP resources can be mocked using WireMock and an SMTP server with SubEthatSMTP.
In production, microservices register with a service registry and are assigned dynamic URLs. To mock external microservices during testing, you’ll need to set the URL explicitly. If you’re using Ribbon, this can be done by adding a property to the application.yml
file of your test environment.
In the example below, the external microservice is named user
:
user:
ribbon:
listOfServers: localhost:9999
Component Tests
Component tests enable the testing of complete use cases from end to end. However, they’re often expensive in terms of setup and execution time. For this reason, their scope must be carefully defined, just like with integration tests. Nevertheless, these tests are essential for validating and documenting the overall behaviour of the application or microservice.
In the context of microservices, component tests can be very cost-effective. You can often leverage the microservice’s existing external API to set the tests up directly without requiring additional elements (like a fake server). Moreover, the limited scope of a microservice makes it feasible to test comprehensively 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 through a single nominal case and a small number of edge cases.
In this context, writing specifications before implementing the feature often yields more focused and straightforward component tests. Collaborating with project stakeholders during this phase is also a best practice, as it helps ensure that the tests accurately reflect the actual requirements.
In terms of tooling, you can use Gherkin for specifications, paired with Cucumber for test execution. As with integration tests, you can use an embedded database. You can also mock HTTP and AMQP clients. These tests are not designed to validate integration with external systems (see Setup a Circuit Breaker with Hystrix, Feign Client and Spring Boot).
Furthermore, you can use MockMvc to perform requests on the microservice’s HTTP API and make assertions on the responses.
@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));
}
}
You can also inject the AMQP channel used by Spring Cloud Stream directly into your test to send AMQP messages.
// 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, fixing the time can help make your 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 service provider and its consumers speak the same language. These tests don’t aim to verify component behaviour but rather to validate the contract between them. They’re instrumental in microservice architectures, where most of the value lies in inter-service communication. In this context, it’s crucial to ensure that no provider breaks the contract its consumers rely on.
The general idea is that consumers write tests that define the provider’s initial state, the request they will send, and the response they expect. The provider should then supply a server in that required state, and the contract should automically be verified against it. This process implies:
- On the consumer side: contract tests are written using the HTTP client. Given a specific provider state, assertions are made on the HTTP response.
- On the provider side: only the HTTP resource should be instantiated, with all its dependencies mocked to reproduce the expected state.
It’s important to remember that contract tests should reflect only the consumer’s real needs. In other words, if a consumer doesn’t use a field, it shouldn’t be tested. This way, the provider remains free to update or remove unused field, and if a test fails, we would know it’s for a good reason.
You can use Pact to write and execute contract tests. It’s a mature tool with plugins for many languages (JVM, Ruby, .NET, JavaScript, Go, Python, etc.). It also integrates well with Spring MVC via the DiUS pact-jvm-provider-spring plugin.
During the execution of consumer tests, Pact generates contracts (called pacts) in JSON format. These can be shared with the provider using a service called the Pact Broker.
Below 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 require the entire platform to be operational to validate full business use cases across multiple microservices. Unfortunately, they’re resource-intensive and slow to execute. These tests can be run manually or automatically on a dedicated platform, but they have to be selected strategically to maximize their benefits.
To Sum Up

Conclusion
Automatic tests play a critical role in software development. A well-designed testing strategy helps produce tests that are more relevant, reliable and maintainable. This article outlined a practical approach to testing Java Spring Boot microservices.