Testing microservices is fundamentally different from testing a monolith. You have distributed state, network boundaries, independent deployments, and inter-service contracts that can drift apart silently. A solid microservices testing strategy with JUnit 6 operates at three distinct levels: integration tests within a single service, contract tests between services, and end-to-end tests across the entire system. This guide covers all three with concrete, production-grade examples.
The Microservices Testing Pyramid

Level 1: Integration Tests Within a Single Service
Integration tests for a microservice verify that all layers of the service work together correctly — controller, service, repository, and database — but in isolation from other services. External service calls are stubbed using WireMock.
<!-- WireMock: HTTP stub server for testing external service calls -->
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-jetty12</artifactId>
<version>3.9.1</version>
<scope>test</scope>
</dependency>
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import org.junit.jupiter.api.extension.RegisterExtension;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@DisplayName("Order Service — integration test with stubbed inventory service")
class OrderServiceIntegrationTest {
// Testcontainers: real PostgreSQL for the order service’s own database
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
// WireMock: stub server simulating the external Inventory Service
@RegisterExtension
static WireMockExtension inventoryServiceStub = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// Point the order service’s datasource to the Testcontainers DB
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Point the order service’s inventory client to the WireMock stub
registry.add("inventory.service.url",
() -> inventoryServiceStub.baseUrl());
}
@Autowired
private TestRestTemplate restTemplate;
@Test
@DisplayName("Creating order succeeds when inventory has sufficient stock")
void creatingOrderSucceedsWhenStockAvailable() {
// Stub: inventory service confirms stock is available
inventoryServiceStub.stubFor(
WireMock.get(WireMock.urlPathEqualTo("/api/inventory/LAPTOP-01"))
.willReturn(WireMock.aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{"productId":"LAPTOP-01","available":true,"quantity":50}")
.withStatus(200))
);
// Act: call the order service
CreateOrderRequest request =
new CreateOrderRequest("[email protected]", "LAPTOP-01", 1);
ResponseEntity<OrderDto> response =
restTemplate.postForEntity("/api/orders", request, OrderDto.class);
// Assert
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertEquals(OrderStatus.CONFIRMED, response.getBody().getStatus());
// Verify the order service actually called the inventory service
inventoryServiceStub.verify(1,
WireMock.getRequestedFor(
WireMock.urlPathEqualTo("/api/inventory/LAPTOP-01")));
}
@Test
@DisplayName("Creating order fails when inventory has no stock")
void creatingOrderFailsWhenOutOfStock() {
inventoryServiceStub.stubFor(
WireMock.get(WireMock.urlPathEqualTo("/api/inventory/SOLD-OUT-01"))
.willReturn(WireMock.aResponse()
.withBody("{"productId":"SOLD-OUT-01","available":false,"quantity":0}")
.withStatus(200))
);
CreateOrderRequest request =
new CreateOrderRequest("[email protected]", "SOLD-OUT-01", 1);
ResponseEntity<ErrorDto> response =
restTemplate.postForEntity("/api/orders", request, ErrorDto.class);
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertTrue(response.getBody().getMessage().contains("out of stock"));
}
}
Level 2: Contract Tests with Spring Cloud Contract
Contract tests prevent consumer-provider drift: the consumer defines what it expects from the provider, and the provider verifies it delivers exactly that. Spring Cloud Contract automates both sides.
Step 1: Provider defines the contract (Groovy DSL)
// File: src/test/resources/contracts/inventory/get-product-inventory.groovy
package contracts.inventory
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "Returns inventory status for a known product"
request {
method GET()
url "/api/inventory/LAPTOP-01"
}
response {
status OK() // HTTP 200
headers {
contentType applicationJson() // Content-Type: application/json
}
body (
productId: "LAPTOP-01",
available: true,
quantity: 50
)
}
}
Step 2: Provider verification test (auto-generated by Spring Cloud Contract)
import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
// Spring Cloud Contract generates the actual test logic from the contract file
// This base class wires up the controller for the generated test to call
@SpringBootTest
@AutoConfigureMessageVerifier
public abstract class InventoryContractBaseTest {
@Autowired
private InventoryController inventoryController;
@BeforeEach
void setUp() {
// Wire the controller into RestAssured for contract verification
RestAssuredMockMvc.standaloneSetup(inventoryController);
}
}
// The generated test class will look like:
// class ContractVerifierTest extends InventoryContractBaseTest {
// @Test
// void validate_get_product_inventory() {
// // Calls GET /api/inventory/LAPTOP-01 and verifies the response
// // matches the contract exactly
// }
// }
Level 3: End-to-End Tests with Docker Compose
import org.testcontainers.containers.DockerComposeContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.File;
// DockerComposeContainer starts ALL microservices defined in docker-compose.yml
// Use for E2E tests that verify complete user journeys across services
@Testcontainers
@Tag("e2e")
@DisplayName("Order Checkout — end-to-end across Order, Inventory, and Payment services")
class CheckoutE2ETest {
// Starts the entire system defined in docker-compose.test.yml
@Container
static DockerComposeContainer<?> environment =
new DockerComposeContainer<>(new File("src/test/resources/docker-compose.test.yml"))
.withExposedService("order-service", 8080)
.withExposedService("inventory-service", 8081)
.withExposedService("payment-service", 8082)
.withLocalCompose(true); // use locally installed docker-compose binary
@Test
@DisplayName("Complete checkout: from product selection to payment confirmation")
void completeCheckoutFlowSucceeds() {
// Retrieve the dynamically allocated ports for each service
String orderServiceUrl = "http://" +
environment.getServiceHost("order-service", 8080) + ":" +
environment.getServicePort("order-service", 8080);
// Step 1: Check product availability in Inventory Service
RestTemplate client = new RestTemplate();
InventoryDto inventory = client.getForObject(
orderServiceUrl + "/api/inventory/LAPTOP-01", InventoryDto.class);
assertTrue(inventory.isAvailable());
// Step 2: Create order in Order Service
CreateOrderRequest orderRequest =
new CreateOrderRequest("[email protected]", "LAPTOP-01", 1);
ResponseEntity<OrderDto> orderResponse = client.postForEntity(
orderServiceUrl + "/api/orders", orderRequest, OrderDto.class);
assertEquals(HttpStatus.CREATED, orderResponse.getStatusCode());
Long orderId = orderResponse.getBody().getId();
// Step 3: Confirm payment went through (event-driven async — poll with timeout)
await().atMost(10, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
OrderDto order = client.getForObject(
orderServiceUrl + "/api/orders/" + orderId, OrderDto.class);
assertEquals(OrderStatus.PAYMENT_CONFIRMED, order.getStatus());
});
}
}
Frequently Asked Questions (FAQs)
Q1: How many E2E tests should I have in a microservices project?
E2E tests should be few and focused on critical user journeys only — typically 5–20 tests covering the most important flows (checkout, registration, order cancellation). They are expensive to maintain, slow to run, and brittle to infrastructure changes. The majority of your confidence should come from contract tests and single-service integration tests, not E2E tests.
Q2: What is the difference between contract tests and integration tests?
Integration tests verify that a single service works correctly internally. Contract tests verify the interface between two services — that what the consumer expects matches what the provider delivers. Contract tests catch breaking API changes without requiring both services to run simultaneously, which is their key advantage in a microservices architecture.
Q3: How do I test asynchronous Kafka or RabbitMQ events in JUnit 6?
Use Testcontainers for a real Kafka/RabbitMQ container and the Awaitility library to poll for async results with a timeout. Awaitility’s await().atMost(10, SECONDS).untilAsserted(() -> ...) pattern cleanly handles eventual consistency without Thread.sleep. Spring’s @KafkaListener tests also support KafkaTemplate and EmbeddedKafka for in-process broker testing.
Q4: Should I use WireMock or Mockito for stubbing external service calls?
Use Mockito for pure unit tests where you mock the client interface directly. Use WireMock when you want to test the full HTTP stack — including serialization, headers, error handling, and timeout behaviour. WireMock is more realistic because it operates at the HTTP level, not the Java interface level. For integration tests that verify HTTP client behaviour end-to-end, WireMock is the right tool.
Q5: How do I handle database state between E2E tests?
E2E tests run against a real system, so @Transactional rollback is not available. Use dedicated test data that includes unique identifiers per test run (e.g., email addresses with a timestamp), clean up via a @AfterEach that calls a cleanup API endpoint, or start from a known Docker Compose snapshot state before each test run. Keeping E2E tests few in number makes state management tractable.
See Also
- JUnit 6 with Testcontainers: Real Database Integration Testing
- JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing
- Testing REST APIs with JUnit 6: MockMvc vs WebTestClient
- Unit vs Integration vs E2E Testing in JUnit 6 (Practical Guide)
- JUnit 6 Tutorial: Complete Series Index
Conclusion
Microservices testing requires discipline at every level. Unit tests stay fast and numerous. Single-service integration tests with WireMock and Testcontainers verify each service works in isolation. Contract tests protect inter-service APIs from silent drift. E2E tests cover critical user journeys end-to-end but stay small and focused. Apply all four layers and your distributed system will have the same confidence as a well-tested monolith — without the fragility of end-to-end-only testing.
Next: Test Data Management Strategies in JUnit 6 Projects — how to organise, generate, and clean up test data across all testing layers.