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.
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
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?
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:
@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:
@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:
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: trueRun 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?
What does contract testing actually give me?
Is it worth testing against real infrastructure?
Related tutorials
- Event-Driven MicroservicesDesigning events that last: domain versus integration events, Avro and schema registry compatibility, event sourcing basics, ordering guarantees and schema evolution.
- Containerizing Spring Boot with DockerBuilding small, fast, secure Spring Boot images: multi-stage builds, BuildKit cache mounts, layered jars, JVM container awareness, distroless bases and vulnerability scanning.
- Distributed Transactions & Saga PatternsWhy two-phase commit fails in microservices, choreography versus orchestration sagas, compensating transactions, the transactional outbox, and idempotent consumers.
- Kubernetes for Java MicroservicesRunning Spring Boot on Kubernetes properly: liveness versus readiness probes, JVM memory inside cgroups, resource requests and limits, autoscaling, and zero-downtime rollouts.