Consumer-Driven Contract Testing with Pact: A Deep Dive
Microservices architectures give teams the freedom to deploy independently — but that freedom comes with a hidden tax: integration failures discovered late in the pipeline, or worse, in production. Consumer-driven contract testing with Pact is the most practical solution to this problem that exists today.
This post covers what contract testing actually is, how Pact implements it, and how to set it up end-to-end with real code examples in Java and Node.js.
Why Integration Testing at the Service Boundary Is Hard
The classic approach to testing microservice integrations is end-to-end testing: spin up all the services, fire real requests through them, and assert on the outcomes. This works at small scale. At any real scale, it becomes a maintenance nightmare.
End-to-end tests are slow, flaky, and give you almost no information about which service broke when something fails. They also require all services to be deployed simultaneously, which defeats the entire point of independent deployability.
The other common approach is to mock downstream services in each consumer's tests. This works for unit testing logic, but it doesn't help you catch the case where the provider actually changes the shape of the response your mock was imitating.
Contract testing sits between these two extremes. It lets you test integrations in isolation — no shared environment needed — while still catching the class of bugs that mocks miss.
What Consumer-Driven Contract Testing Actually Means
In a contract test, the consumer (the service making the request) defines what it expects from a provider (the service receiving the request). That definition is the contract. The provider then runs tests to verify it actually satisfies that contract.
"Consumer-driven" is the key phrase. The contract is owned by the consumer, not negotiated jointly. This makes sense: the provider doesn't know how each consumer uses its API. The consumer knows exactly what fields it reads, what status codes it handles, and what formats it expects.
Pact is the most widely used implementation of this pattern. It works like this:
- The consumer writes a test that defines the expected interaction.
- Pact captures that interaction and generates a "pact file" (a JSON contract).
- The pact file is published to a Pact Broker.
- The provider pulls the pact file and replays the interactions against itself.
- If the provider's responses match the contract, the test passes.
Setting Up Pact in a Node.js Consumer
Let's say you have an order-service that calls a product-service to fetch product details. Here's how you'd write a Pact consumer test in Node.js using Jest.
First, install the dependencies:
npm install --save-dev @pact-foundation/pact jestNow write the consumer test:
// order-service/src/__tests__/productService.pact.test.js
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, eachLike, integer, string } = MatchersV3;
const path = require('path');
const { getProduct } = require('../clients/productClient');
const provider = new PactV3({
consumer: 'order-service',
provider: 'product-service',
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
describe('ProductService Pact', () => {
describe('GET /products/:id', () => {
it('returns product details for a valid product ID', async () => {
await provider
.given('a product with ID 42 exists')
.uponReceiving('a request for product 42')
.withRequest({
method: 'GET',
path: '/products/42',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(42),
name: string('Widget Pro'),
price: like(19.99),
inStock: like(true),
},
})
.executeTest(async (mockProvider) => {
const product = await getProduct(mockProvider.url, 42);
expect(product.id).toBe(42);
expect(product.name).toBeDefined();
expect(typeof product.price).toBe('number');
});
});
it('returns 404 for a non-existent product', async () => {
await provider
.given('no product with ID 999 exists')
.uponReceiving('a request for non-existent product 999')
.withRequest({
method: 'GET',
path: '/products/999',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 404,
body: {
error: string('Product not found'),
},
})
.executeTest(async (mockProvider) => {
await expect(getProduct(mockProvider.url, 999)).rejects.toThrow('404');
});
});
});
});When this test runs, Pact starts a mock server, verifies that your productClient code makes the requests correctly, and writes a pact file to the pacts/ directory.
Writing the Provider Verification in Java
Now on the product-service side, written in Java with Spring Boot:
// product-service/src/test/java/com/example/product/PactProviderTest.java
@Provider("product-service")
@PactBroker(
url = "${PACT_BROKER_URL}",
authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(SpringExtension.class)
public class PactProviderTest {
@LocalServerPort
private int port;
@MockBean
private ProductRepository productRepository;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
@State("a product with ID 42 exists")
void productExists() {
Product product = new Product(42L, "Widget Pro", new BigDecimal("19.99"), true);
when(productRepository.findById(42L)).thenReturn(Optional.of(product));
}
@State("no product with ID 999 exists")
void productDoesNotExist() {
when(productRepository.findById(999L)).thenReturn(Optional.empty());
}
}The @State methods set up the test data for each provider state defined in the pact file. This is where most of the work lives — making sure each state actually reflects what the contract says should be true.
Publishing Pacts to the Pact Broker
A pact file sitting on a developer's machine is not useful. You need a central broker that both sides can access. Pact maintains an open-source broker you can self-host, and there's a hosted version at pactflow.io.
Publish from the consumer CI pipeline:
npx pact-broker publish ./pacts \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKEN \
--consumer-app-version $(git rev-parse HEAD) \
--branch $(git rev-parse --abbrev-ref HEAD)Then in the provider's CI pipeline, verification runs automatically against all pacts from all consumers.
Can I Deploy? The can-i-deploy Check
The most powerful Pact feature is can-i-deploy. Before deploying any service, you query the broker to check whether the version you're deploying is compatible with all its counterparts currently in production.
npx pact-broker can-i-deploy \
--pacticipant product-service \
--version $(git rev-parse HEAD) \
--to-environment production \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKENIf this returns a non-zero exit code, your deployment pipeline stops. You've caught the breaking change before it reached production.
Matching Rules and What to Be Precise About
Pact gives you several matching strategies. Choosing the wrong one is the most common mistake teams make.
like() matches type only — it doesn't check the value, just that the field exists and has the right type. Use this for most fields.
eachLike() matches an array where every element matches the given template. Use this for arrays.
term() matches against a regex. Use this for formats like dates, UUIDs, or email addresses.
Exact matching (plain values without matchers) checks the exact value. Use this only when the exact value truly matters to the consumer — for example, specific enum values or status codes.
A mistake teams often make: using exact matching for timestamps or IDs, causing tests to fail not because the contract is broken but because the test data drifted.
Handling Authentication in Contracts
Many services require auth headers. The right approach is to verify that the structure of the auth header is present, not its exact value, since tokens rotate.
.withRequest({
method: 'GET',
path: '/products/42',
headers: {
Authorization: term({
generate: 'Bearer test-token-for-pact',
matcher: 'Bearer [A-Za-z0-9._-]+',
}),
},
})On the provider side, configure a request filter to replace the token with a valid test token before the request hits your actual auth middleware:
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@RequestFilter
RequestSpecification addAuthHeader(RequestSpecification requestSpecification) {
return requestSpecification.header("Authorization", "Bearer " + testJwt);
}Versioning and Breaking Change Detection
Pact's broker tracks which consumer version is verified against which provider version. When a provider wants to change an API, they can check which consumers would be affected before making the change.
This changes the conversation around API evolution. Instead of "we'll send an email to all teams when we change this endpoint," you get a verifiable, automated gate that makes breaking changes visible before they happen.
Common Pitfalls
Testing the wrong layer: Pact tests should cover the HTTP contract, not business logic. If you find yourself mocking databases and running complex setup in consumer tests, you've gone too far.
Overly specific contracts: Contracts that assert on every field, including fields the consumer doesn't use, create false coupling. If order-service doesn't use product.weight, don't include it in the contract. This gives the provider freedom to change unused fields.
Skipping provider states: Provider states that do nothing (empty @State methods) mean your verification tests aren't actually testing what they claim. Every state needs to configure the system into the described condition.
Not running verification in CI: Contract testing only works if the provider verification runs on every commit. A pact file that isn't verified regularly becomes stale and gives false confidence.
When Contract Testing Isn't Enough
Contract testing covers the HTTP API boundary well. It doesn't cover:
- Message queue schemas (use Pact's message pact feature for this)
- Database schema changes that affect shared databases (avoid shared databases)
- Timing-dependent interactions
- Load and performance characteristics
For these, you need other tools. But for the core problem of "did I break the API contract another team depends on," Pact is the right tool, and it's significantly better than any alternative at that specific job.
Wrapping Up
Consumer-driven contract testing with Pact solves a real, expensive problem in microservices architectures. The setup cost is real but front-loaded. Once the pipeline is in place, teams gain the confidence to deploy independently without manual coordination — which is what microservices were supposed to enable in the first place.
The investment pays off fastest in organizations with more than five services and more than two teams. If you're still at one team and three services, you can probably manage with careful integration tests. But once you're past that threshold, every week without contract testing is a week of accumulated coordination overhead and deployment fear.