Testing a monolith is mostly testing one codebase. Testing microservices means proving that dozens of independently deployed services still work together — without a slow, flaky end-to-end suite that nobody trusts. The teams that ship Java microservices confidently do it with a deliberate test strategy: a wide base of fast tests, real-dependency integration tests, and contract tests that catch cross-service breakage without spinning up the whole world. This deep dive lays it out.
flowchart TD
E2E[End-to-end: very few] --> Int[Integration: some]
Int --> Unit[Unit: many, fast]
The pyramid is a guide to proportion: lots of cheap, fast tests at the bottom, progressively fewer expensive ones toward the top.
The classic anti-pattern is the “ice-cream cone”: a thin base of unit tests and a heavy reliance on slow end-to-end tests. It produces suites that take hours, fail intermittently for environmental reasons, and erode trust until people ignore red builds. Push testing down the pyramid.
Unit tests should not load Spring. Test a class in isolation with plain JUnit 5, mock collaborators with Mockito, and assert with AssertJ. Because they’re fast, you can have thousands and run them on every save.
@ExtendWith(MockitoExtension.class)
class PricingServiceTest {
@Mock DiscountRepository discounts;
@InjectMocks PricingService service;
@Test void appliesLoyaltyDiscount() {
when(discounts.forTier("GOLD")).thenReturn(new Discount(0.10));
assertThat(service.price(100, "GOLD")).isEqualByComparingTo("90.00");
}
}
When you do need the framework, don’t boot the whole application — load only the slice under test. @WebMvcTest wires the web layer (controllers, JSON, validation) with the service layer mocked; @DataJpaTest wires only the persistence layer. Slices are far faster than a full @SpringBootTest and keep failures focused.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService service;
@Test void returns201OnCreate() throws Exception {
when(service.place(any())).thenReturn(new OrderDto(42L));
mvc.perform(post("/v1/orders").contentType(APPLICATION_JSON).content("{...}"))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"));
}
}
The trap here is testing against an in-memory database like H2: it parses SQL differently, lacks your real database’s features, and lets bugs pass that fail in production. Testcontainers runs the real PostgreSQL, Kafka, or Redis in Docker for the duration of the test — production fidelity with no shared, flaky test environment.
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
@Container
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", db::getJdbcUrl);
r.add("spring.datasource.username", db::getUsername);
r.add("spring.datasource.password", db::getPassword);
}
@Autowired OrderRepository repo;
@Test void persistsAndQueries() {
repo.save(new Order("SKU-1", 2));
assertThat(repo.findByStatus(OrderStatus.NEW)).hasSize(1);
}
}
Reuse containers across the suite (singleton pattern or Testcontainers reuse) so you pay startup once, not per class.
Here is the core difficulty: service A calls service B. A’s tests mock B — but if the team behind B changes its API, A’s mocks happily keep passing while production breaks. End-to-end tests would catch it, but they’re slow and flaky. The answer is contract testing.
In consumer-driven contract testing, the consumer (A) declares exactly the requests it makes and the responses it expects; that expectation becomes a contract. The contract is then verified against the real provider (B) in B’s own pipeline. If B makes a change that violates the contract, B’s build fails immediately — A learns about the break without A and B ever being deployed together.
Two dominant tools in the Java world:
| Tool | Approach |
|---|---|
| Pact | Consumer test records interactions into a pact file; a broker shares it; the provider replays and verifies it. Language-agnostic, great for polyglot estates. |
| Spring Cloud Contract | Contracts (Groovy/YAML) generate consumer stubs and provider verification tests. Natural fit for an all-Spring shop. |
// Consumer side (Pact): define what we expect from the provider
@Pact(consumer = "orders-service", provider = "pricing-service")
RequestResponsePact price(PactDslWithProvider builder) {
return builder.given("SKU-1 exists")
.uponReceiving("a price request")
.path("/v1/price/SKU-1").method("GET")
.willRespondWith()
.status(200).body("{\"sku\":\"SKU-1\",\"amount\":9.99}")
.toPact();
}
// The pact is published to a broker; pricing-service's pipeline verifies
// it against the real provider and fails if the contract is broken.
The payoff: you get most of the confidence of end-to-end testing about service compatibility, at the speed and reliability of unit-style tests, and each team can deploy independently knowing the contracts are green.
| Concern | Test type |
|---|---|
| Business logic, edge cases | Unit |
| Controller / serialization / validation | @WebMvcTest slice |
| Repository / real SQL / migrations | @DataJpaTest + Testcontainers |
| Cross-service API compatibility | Contract test (Pact / Spring Cloud Contract) |
| A few critical user journeys | End-to-end (sparingly) |
Confidence in a Java microservice platform comes from proportion and fidelity, not volume: a broad base of fast unit tests, slice tests for the web and data layers, Testcontainers for production-accurate integration, and consumer-driven contracts to catch cross-service breakage without brittle end-to-end suites. Keep tests deterministic and behavior-focused, run them in stages in CI, and you can let dozens of teams deploy independently — knowing the build going green actually means the system still works together.
What is consumer-driven contract testing?
Each API consumer defines the requests it makes and the responses it expects as a contract. That contract is verified against the provider in its own pipeline, so the provider learns immediately if a change would break a consumer — catching integration breakage without slow, brittle end-to-end tests. Pact and Spring Cloud Contract are the common tools.
Why use Testcontainers instead of an in-memory database for tests?
An in-memory database (like H2) behaves differently from your production database, so tests can pass against H2 and fail in production. Testcontainers runs the real database (and Kafka, Redis, etc.) in Docker for the test, giving you production-fidelity integration tests.