Testing Microservices
Consumer-driven contract testing with Pact, the testing pyramid for distributed systems, and isolation vs integration environments.
Why the normal testing pyramid needs rethinking
The classic testing pyramid — lots of unit tests, fewer integration tests, very few end-to-end tests — still applies to microservices, but "end-to-end" means something much more expensive here than in a monolith. A monolith's end-to-end test spins up one process. A microservices system's end-to-end test needs every service involved in the flow running simultaneously, wired together correctly, which is slow, flaky (any one service being briefly unhealthy fails the whole test), and expensive to maintain as the number of services grows. That cost is exactly why a middle layer — contract testing — earns its place in the pyramid specifically for distributed systems.
/\
/ \ End-to-end (few) — the full system, real network calls
/----\
/ Cont-\ Contract tests (some) — verify one service's API
/ ract \ against what its consumers actually expect
/----------\
/ Integration\ — one service + its real DB/queue, mocked/stubbed peers
/--------------\
/ Unit tests \ — pure logic, no network or DB at all
/--------------------\
Contract testing: catching breakage without a full integration environment
The problem contract testing solves: InventoryService changes its response shape, and the only way anyone finds out OrdersService depended on the old shape is a production incident, because nothing outside InventoryService's own test suite ever checked what its consumers actually expected from it.
Consumer-driven contract testing flips the direction: the consumer (OrdersService) writes down exactly what it expects from a call to InventoryService, that expectation is published as a contract, and InventoryService's own test suite replays it against the real service to verify it's still honored — all without either service's full stack running against the other.
Using Pact, the most widely used tool for this, the consumer side looks like an ordinary test with a mock server standing in for the provider:
// OrdersService's test suite — defines what it expects from InventoryService
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like } = MatchersV3;
const provider = new PactV3({
consumer: 'orders-service',
provider: 'inventory-service',
});
test('reserving stock for an in-stock item succeeds', async () => {
provider
.given('SKU-100 has stock available')
.uponReceiving('a request to reserve stock')
.withRequest({
method: 'POST',
path: '/reservations',
body: { sku: 'SKU-100', quantity: 2 },
})
.willRespondWith({
status: 201,
body: { reservationId: like('res-9f31'), status: like('CONFIRMED') },
});
await provider.executeTest(async (mockServer) => {
const client = new InventoryClient(mockServer.url);
const result = await client.reserveStock('SKU-100', 2);
expect(result.status).toBe('CONFIRMED');
});
});
Running this test does two things at once: it verifies OrdersService's own client code against the expected shape, and it generates a pact file — a JSON document recording exactly this expectation. That file is published to a shared broker, and InventoryService's CI pipeline then runs a provider verification: it replays every consumer's recorded requests against a real, running instance of InventoryService and checks the real responses still satisfy every contract on file.
// InventoryService's own test suite — verifies it still satisfies every consumer's contract
const { Verifier } = require('@pact-foundation/pact');
new Verifier({
provider: 'inventory-service',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: 'https://pact-broker.internal',
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
}).verifyProvider();
If someone on the InventoryService team renames status to reservationStatus, this verification fails in InventoryService's own CI pipeline, immediately, without OrdersService needing to be running at all — that's the entire point: it catches an integration break at the speed and isolation of a unit test, instead of at the cost of a full end-to-end run or, worse, a production incident.
Testing in isolation vs. integration environments
In isolation means testing one service on its own machine, with everything it depends on replaced by something fast and fully controlled:
// Unit test — pure business logic, no network or database involved at all
test('rejects a reservation for a negative quantity', () => {
expect(() => validateReservation({ sku: 'SKU-100', quantity: -1 }))
.toThrow('quantity must be positive');
});
// Integration test — the service's real code talking to a REAL database,
// run in a disposable container for this test only (via Testcontainers)
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
let container, repository;
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
repository = new InventoryRepository(container.getConnectionUri());
await repository.migrate();
});
afterAll(() => container.stop());
test('reserving stock decrements the available count', async () => {
await repository.seed({ sku: 'SKU-100', available: 10 });
await repository.reserve('SKU-100', 3);
const stock = await repository.findBySku('SKU-100');
expect(stock.available).toBe(7);
});
Testcontainers here spins up a real, disposable Postgres in Docker just for this test run — the integration test exercises real SQL against a real database engine, but every one of InventoryService's peers (OrdersService, PaymentService) is still absent entirely, either mocked or simply not involved, which is what keeps this fast and independently runnable on a laptop or in CI with no shared environment required.
An integration (or staging) environment, by contrast, has multiple real services deployed together, talking over a real network, usually as the last check before production:
# docker-compose.staging.yml — several real services running together for
# end-to-end verification, as close to the production topology as practical
services:
orders-service:
image: orders-service:pr-482
inventory-service:
image: inventory-service:latest
payment-service:
image: payment-service:latest
postgres:
image: postgres:16
rabbitmq:
image: rabbitmq:3.13-management
This is where genuine end-to-end tests run — "place an order, verify stock decrements, verify a payment was charged, verify a confirmation event was published" — exercising the real integration between real services. It's also the slowest, most expensive, and most failure-prone layer, precisely because it depends on every participating service being deployed and healthy at once — which is exactly why the pyramid keeps it small and pushes as much verification as possible down into unit, integration-in-isolation, and contract tests instead.
Comparing the layers
| Layer | What's real | What's faked/absent | Speed | Catches |
|---|---|---|---|---|
| Unit | Just the code under test | Everything else | Fastest | Logic bugs |
| Integration (in isolation) | This service's own DB/queue (via a disposable container) | Every other service | Fast | Query bugs, schema mismatches |
| Contract | The provider's real API, replayed against recorded consumer expectations | The consumer service itself isn't running | Fast, but needs a broker | Breaking API changes between services |
| End-to-end | Every service, deployed together | Nothing — real network calls throughout | Slow, sometimes flaky | Real cross-service integration bugs |
Common mistakes
- Skipping contract tests and relying on end-to-end tests to catch every cross-service breakage — by the time an end-to-end suite catches it, the fix is slower to diagnose (which of five services actually broke?) and the whole suite runs far less often than a fast per-service pipeline would.
- Writing "integration" tests that mock the very database or queue they're meant to be verifying real behavior against — this quietly turns an integration test into a unit test with extra ceremony, and stops catching real query or serialization bugs.
- Letting the end-to-end environment become the only place a cross-service bug is ever caught, then accepting a slow, flaky suite as normal instead of pushing more of that verification down into contract tests, where it belongs.
- Never re-verifying a published contract when a provider changes — a Pact contract only protects you if the provider's CI pipeline actually runs verification against it on every change, not just when the contract was first written.