Testing Java Microservices: Test Pyramid, Testcontainers & Contracts

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.

TL;DR: Follow the test pyramid — many fast unit tests, fewer integration tests, very few end-to-end. Use Spring Boot test slices for speed and Testcontainers for production-fidelity integration tests. Replace brittle end-to-end suites with consumer-driven contract testing (Pact or Spring Cloud Contract) so providers learn instantly when a change would break a consumer.
Tailor your resume to a senior Java role →
flowchart TD
  E2E[End-to-end: very few] --> Int[Integration: some]
  Int --> Unit[Unit: many, fast]
        
The test pyramid: a wide base of fast unit tests, fewer integration tests, very few end-to-end.

The test pyramid (and the anti-pattern)

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.

Fast unit tests

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");
  }
}

Spring Boot test slices

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"));
  }
}

Integration tests with Testcontainers

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.

The integration-testing problem microservices add

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.

Consumer-driven 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:

ToolApproach
PactConsumer 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 ContractContracts (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.

What to test where

ConcernTest type
Business logic, edge casesUnit
Controller / serialization / validation@WebMvcTest slice
Repository / real SQL / migrations@DataJpaTest + Testcontainers
Cross-service API compatibilityContract test (Pact / Spring Cloud Contract)
A few critical user journeysEnd-to-end (sparingly)

Practices that keep suites trustworthy

Takeaways

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.

Frequently asked questions

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.

Land your next Java role — tailor your resume with AI →