Web Development · Testing
Contract Testing for Microservices: Catching Breaking Changes Before Production Does
Integration tests that spin up every dependent service are slow and flaky. Contract testing checks that a consumer and provider agree on a shape without either one running, and catches the breaking changes end-to-end tests miss. Here's how to set it up with Pact.
Prathviraj Singh
6 min read
Sponsored
An integration test that spins up three dependent services to check whether one JSON field got renamed is expensive for what it verifies. It’s slow, it’s flaky for reasons that have nothing to do with the actual question, and by the time it fails in CI the breaking change has usually already been merged. Contract testing exists to answer that same narrow question, does the provider’s response actually match what the consumer expects, without either service needing to run against the other at all.
The problem contract testing actually solves
Microservices split a system across team and deploy boundaries, and that split is exactly where the classic integration test suite starts to strain. A consumer service calls a provider’s API. The provider team ships a change: a field gets renamed, a type changes from string to number, a previously optional field becomes required. Nothing in the provider’s own test suite catches it, because the provider’s tests check the provider’s behavior in isolation. Nothing in the consumer’s test suite catches it either, because the consumer’s tests typically mock the provider’s response, and the mock still reflects the old shape. The break only shows up when both services actually run together, which in a lot of organizations means production.
Full end-to-end integration tests are the traditional answer, spin up real (or close-to-real) instances of every service in the call path and exercise the actual interaction. That works, but it’s slow enough that teams run it sparingly, and it’s flaky for reasons unrelated to the API contract itself: a test database that’s in a weird state, an unrelated service timing out, network flakiness in the CI environment. A test suite that fails for reasons unrelated to what it’s supposed to verify gets ignored over time, which defeats the purpose.
Contract testing narrows the question to exactly the thing that actually breaks: does this response match what the consumer expects. It doesn’t need either service fully running. It needs a stored, versioned description of the expected interaction, and each side tests against that description independently.
How it actually works: the consumer-driven pattern
Most contract testing tools, Pact included, implement a consumer-driven pattern. The consumer, the service making the calls, writes the contract based on what it actually needs. The provider then verifies its real implementation against that same contract.
Step 1: The consumer writes a contract test, describing an interaction it expects to work:
// consumer/order-service.pact.test.js
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, integer } = MatchersV3;
const provider = new PactV3({
consumer: 'OrderService',
provider: 'InventoryService',
});
describe('Inventory check', () => {
it('returns stock level for a known SKU', async () => {
provider
.given('SKU ABC-123 is in stock')
.uponReceiving('a request for stock level')
.withRequest({
method: 'GET',
path: '/inventory/ABC-123',
})
.willRespondWith({
status: 200,
body: {
sku: 'ABC-123',
quantity: integer(42),
warehouse: like('EAST-1'),
},
});
await provider.executeTest(async (mockServer) => {
const client = new InventoryClient(mockServer.url);
const result = await client.getStock('ABC-123');
expect(result.quantity).toBeGreaterThan(0);
});
});
});
Running this test does two things: it verifies the consumer’s own code handles that response shape correctly, and it generates a contract file, a JSON document describing the request and expected response, that gets published to a Pact broker (a shared service that stores and versions contracts).
Step 2: The provider verifies against the published contract, in its own test suite, without needing the consumer’s code at all:
// provider/inventory-service.pact-verify.test.js
const { Verifier } = require('@pact-foundation/pact');
new Verifier({
provider: 'InventoryService',
providerBaseUrl: 'http://localhost:4000',
pactBrokerUrl: 'https://your-org.pactflow.io',
providerVersion: process.env.GIT_COMMIT,
publishVerificationResult: true,
stateHandlers: {
'SKU ABC-123 is in stock': async () => {
await seedDatabase({ sku: 'ABC-123', quantity: 42, warehouse: 'EAST-1' });
},
},
}).verifyProvider();
The stateHandlers block sets up the actual data the provider needs to make the interaction real (seeding a database record, in this case) rather than mocking the provider’s own logic. This is the part that makes contract testing more trustworthy than a hand-maintained API spec document: it’s verified against the real running provider code, using real state, not just documented and hoped to stay accurate.
The broker is what makes this safe to automate
A Pact broker isn’t optional infrastructure, it’s the piece that turns contract testing from “a test pattern” into “a deploy gate.” It stores every published contract, tracks which provider versions have been verified against which consumer contracts, and exposes a can-i-deploy check:
pact-broker can-i-deploy \
--pacticipant InventoryService \
--version $GIT_COMMIT \
--to-environment production
That command answers a real question before a deploy proceeds: has this exact version of the provider been verified against every consumer contract currently relying on it? If a consumer team published a contract last week that this provider version hasn’t been checked against yet, the deploy gate fails, before the breaking change reaches production, not after a consumer’s error rate spikes and someone starts a postmortem.
What this replaces, and what it doesn’t
Contract testing removes the need for broad, slow, cross-service integration test suites whose real (if often unstated) purpose was catching API compatibility breaks between services. It doesn’t replace testing actual business logic that spans multiple services, a multi-step checkout flow that touches inventory, payment, and shipping still needs its own test coverage for the workflow itself, not just each individual API shape along the way. The strangler fig pattern for legacy migrations runs into a related version of this problem: two systems handling the same domain concurrently need to agree on shape at every boundary between them, and contract testing is a direct way to enforce that agreement continuously instead of hoping manual coordination catches every drift.
The teams that get the most value from this are the ones with enough services, and enough team boundaries between them, that “read the other team’s Slack channel to find out about API changes” has stopped being a reliable process. If that’s where your architecture already is, and your integration test suite has become the thing everyone quietly skips because it’s slow and flaky, contract testing is worth evaluating before the next silent breaking change reaches production instead of a staging environment. Our team has helped several clients introduce this pattern incrementally, starting with the one or two service boundaries that actually break most often.
Frequently asked questions
- What is contract testing?
- Contract testing verifies that two services, a consumer that calls an API and a provider that serves it, agree on the shape of their interaction: the request format, the response format, required fields, and types. The consumer defines a contract describing what it expects; the provider runs tests against that same contract to confirm it actually delivers what's expected, without either service needing the other running during the test.
- How is contract testing different from integration testing?
- A full integration test spins up real instances of both services, or close approximations, and exercises them together, which is accurate but slow and prone to failing for reasons unrelated to the actual contract (a flaky database, a slow network call, an unrelated service being down). Contract testing verifies the same compatibility question, does this response match what the consumer expects, without either service running against the other, using a stored, versioned description of the expected interaction instead.
- What's a consumer-driven contract?
- It's the specific pattern most contract testing tools implement: the consumer, the service making the API calls, writes the contract based on what it actually needs from the response. The provider then verifies against that consumer-authored contract, rather than the provider unilaterally documenting its API and hoping consumers happen to match. This flips the usual direction of API documentation and catches mismatches the provider wouldn't think to test for on its own.
- Do I still need integration tests if I have contract tests?
- Yes, for a smaller set of things. Contract tests verify API shape and compatibility between services; they don't verify business logic that spans multiple services, complex multi-step workflows, or things like actual database state after a chain of calls. What contract testing removes is the need for broad, slow integration tests whose only real job was catching API compatibility breaks, which is a common but narrow reason for most integration test suites to exist in the first place.
- What happens if a provider change breaks a contract?
- The provider's contract verification test fails in their own CI pipeline, before the breaking change ships, rather than surfacing as a runtime error in the consumer's production environment days or weeks later. With a contract broker in the loop, a can-i-deploy check can also block the provider's deployment entirely until every consumer's contract is satisfied by the new version.
Sources
Sponsored
More from this category
More from Web Development
R.01 Webhook Design: Signatures, Retries, and Idempotency Done Right
R.02 Node.js Is Moving to One Major Release a Year. What That Means for Your Upgrade Plan
R.03 WebMCP: How Chrome Lets a Website Expose Its Own Tools to AI Agents
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored