Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Microservices Testing Strategies

A testing strategy for distributed systems: where the pyramid changes shape, consumer-driven contract testing, component tests with Testcontainers, and why end-to-end tests fail you.

Advanced5 min readUpdated
On this page

A monolith's test pyramid has one integration point: the database. A distributed system has one per service boundary, and the naive response — test everything end to end — produces a suite too slow and too flaky to trust.

Key Takeaways

  • Most bugs are still unit-testable. Distribution does not change that.
  • Contract tests replace most cross-service integration testing, at unit-test speed.
  • Component tests verify one service against real infrastructure with stubbed neighbours.
  • Use Testcontainers, not embedded substitutes — the differences are where bugs hide.
  • Keep end-to-end tests to a handful of critical paths.

The shape

Contract tests sit where integration tests would, giving the same confidence without deploying both services together.

One property of that shape is easy to miss and does most of the work: each layer runs in a different place and fails at a different person. Unit and slice tests fail the author immediately. Component tests fail the owning team's pipeline. Contract tests fail whoever broke the contract, which is the whole point — an integration bug caught in a shared end-to-end environment fails everybody at once and nobody in particular, and the resulting investigation costs more than the bug.

Contract testing

The problem contract testing solves: the order service calls the customer service. How does the customer team learn they broke it, without running the order service in their pipeline?

The expectation is verified in the producer's pipeline, so a breaking change fails the build of whoever broke it.
src/test/resources/contracts/customer/shouldReturnCustomer.groovy
Contract.make {
    description "returns a customer with tier and country"
    request {
        method GET()
        url '/api/v1/customers/cus_8Fj3kQ'
        headers { header('Accept', applicationJson()) }
    }
    response {
        status OK()
        headers { contentType(applicationJson()) }
        body([ id: 'cus_8Fj3kQ', tier: 'PREMIUM', country: 'DE' ])
        bodyMatchers {
            jsonPath('$.id', byRegex('cus_[A-Za-z0-9]+'))
            jsonPath('$.tier', byRegex('STANDARD|PREMIUM|INTERNAL'))
        }
    }
}

On the producer side, Spring Cloud Contract generates a test from that file and runs it against the real controller. On the consumer side it publishes a stub jar:

OrderServiceContractTest.java
@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.acme:customer-service:+:stubs:8090",
    stubsMode = StubRunnerProperties.StubsMode.REMOTE)
class OrderServiceContractTest {
 
    @Autowired OrderService orders;
 
    @Test
    void appliesPremiumDiscount() {
        // Hits the stub, which is guaranteed to match what the producer actually
        // returns — because the producer's build verifies exactly this.
        var order = orders.place(new PlaceOrderCommand("cus_8Fj3kQ", lines()));
        assertThat(order.discountPercent()).isEqualTo(10);
    }
}

The bodyMatchers block is what stops the contract being brittle. Asserting on the exact string cus_8Fj3kQ would fail whenever test data changed; asserting on the pattern captures the actual contract, which is the shape.

Component tests

A component test exercises one service completely — real HTTP, real database, real broker — with its outbound dependencies stubbed:

OrderComponentTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderComponentTest {
 
    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
 
    @Container @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.8.0"));
 
    static WireMockServer customerService = new WireMockServer(options().dynamicPort());
 
    @BeforeAll static void start() { customerService.start(); }
 
    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("app.customer.base-url", customerService::baseUrl);
    }
 
    @Autowired TestRestTemplate http;
    @Autowired KafkaTestConsumer events;
 
    @Test
    void placingAnOrderPersistsItAndPublishesAnEvent() {
        customerService.stubFor(get(urlPathMatching("/api/v1/customers/.*"))
                .willReturn(okJson("""
                    {"id":"cus_1","tier":"PREMIUM","country":"DE"}
                    """)));
 
        var response = http.postForEntity("/api/v1/orders", request(), OrderResponse.class);
 
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        // The event is part of the contract, so assert on it like any other output.
        await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
                assertThat(events.received("orders"))
                        .anySatisfy(e -> assertThat(e.orderId()).isNotBlank()));
    }
}

This is the highest-value test in a microservices codebase. It exercises the HTTP contract, the persistence layer against a real engine, the transaction boundary, the outbox and the published event — everything the service is responsible for — without needing any other service to exist.

Chaos testing

Resilience configuration that has never been exercised is a hypothesis. Chaos Monkey for Spring Boot injects failures without touching application code:

application-chaos.yml
chaos:
  monkey:
    enabled: true
    watcher: { component: false, controller: false, repository: true, rest-template: true }
    assaults:
      level: 5                 # affect one call in five
      latency-active: true
      latency-range-start: 2000
      latency-range-end: 5000
      exceptions-active: true

Run it in staging with load and watch whether circuit breakers open, fallbacks return, and alerts fire. The point is not to break things — it is to confirm that breaking them produces the degradation you designed rather than a cascade.

Test data

Shared mutable test data is the main source of flakiness in a distributed suite. Two rules remove most of it.

Each test creates what it needs. A test that depends on a row another test inserted will fail when tests run in a different order or in parallel — and CI will do both eventually.

Use builders with sane defaults. Set only the fields the test is about, so the intent is visible in the three lines that differ rather than buried in a fifty-field constructor.

For the database, prefer creating fresh rows over @Sql scripts that grow into a shared fixture nobody dares change. Where a test genuinely needs a large dataset, generate it in the test with a factory rather than checking in a snapshot that silently drifts from the schema.

What to take away

Keep the bulk of your assertions in unit and slice tests. Use contract tests at every service boundary so breaking changes fail the producer's build. Write component tests against real infrastructure with stubbed neighbours — that is where the best confidence-per-second is. Keep end-to-end tests few and precious, and exercise your resilience configuration deliberately.

Frequently Asked Questions

Why not just write end-to-end tests?
They are slow, they need every service deployed together, and they fail for reasons unrelated to the change under test — a flaky dependency, stale data, a timing race. A suite that fails a third of the time for spurious reasons gets ignored, which is worse than not having it. Keep a handful for critical paths only.
What does contract testing actually give me?
A build failure at the producer when it breaks a consumer, without either team running the other service. The consumer declares its expectations, the producer verifies against them in its own pipeline. It catches the integration bugs end-to-end tests catch, at unit-test speed and reliability.
Is it worth testing against real infrastructure?
Yes. H2 differs from PostgreSQL in null ordering, upserts, JSON and locking; an embedded broker differs from Kafka in rebalancing and offset semantics. Those differences are exactly where production bugs live. Testcontainers costs seconds and removes the class entirely.

Related tutorials