Contract Testing for Microservices: A Practical Guide with Pact
Integration testing microservices is notoriously painful. You need real instances of every dependent service, a shared environment where everyone's changes play nicely together, and the patience to debug failures caused by a schema change three teams made two weeks ago. Contract testing — specifically consumer-driven contract testing — cuts through all of this complexity.
The core idea is elegant: instead of spinning up full environments for integration testing, each service tests only the interface it depends on. The consumer defines what it expects from a provider. The provider verifies it can meet those expectations. Both sides stay in sync without ever needing to coordinate a shared test environment.
Pact is the most widely adopted framework for this approach, supporting JavaScript, Java, Python, Go, Ruby, and more. This guide walks through the complete workflow: writing consumer tests, verifying the provider, setting up the Pact Broker, and wiring everything into CI/CD.
The Problem Contract Testing Solves
Imagine a frontend service that calls an order-service to fetch order details. The frontend expects this response shape:
{
"orderId": "ord_123",
"status": "shipped",
"items": [
{ "sku": "PROD-1", "quantity": 2, "price": 29.99 }
],
"totalAmount": 59.98
}The order-service team, unaware that totalAmount is consumed by the frontend, renames it to total in a refactoring. The API still returns 200. Unit tests pass. E2E tests in the staging environment don't cover this field. The bug ships to production.
Contract testing would have caught this in CI: the consumer's contract specifies totalAmount, the provider verification step runs that contract against the new code, it fails, and the PR is blocked.
Understanding Consumer-Driven Contracts
The flow works like this:
- Consumer writes a test that defines what it sends and what it expects back
- Pact generates a contract file (a JSON pact) from that test
- Contract is published to a Pact Broker (shared repository of contracts)
- Provider verifies the contract by replaying the interactions against its actual implementation
- Both parties can deploy independently once their sides are verified
This is fundamentally different from a provider-defined API spec (like OpenAPI) where the provider decides the shape and consumers adapt. In consumer-driven contracts, the consumer's real usage drives the contract — you only test what's actually consumed.
Setting Up Pact in a JavaScript Consumer
Install the Pact consumer library:
npm install --save-dev @pact-foundation/pactHere's a complete consumer test for the frontend calling order-service:
// order-service.consumer.test.js
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, eachLike, regex } = MatchersV3;
const path = require('path');
const { OrderServiceClient } = require('../src/clients/order-service');
const provider = new PactV3({
consumer: 'frontend',
provider: 'order-service',
dir: path.resolve(process.cwd(), 'pacts'), // where pact files are saved
logLevel: 'warn',
});
describe('OrderService Contract Tests', () => {
describe('GET /orders/:id', () => {
it('returns order details for a valid order ID', async () => {
await provider
.given('an order with ID ord_123 exists')
.uponReceiving('a request for order details')
.withRequest({
method: 'GET',
path: '/orders/ord_123',
headers: {
Accept: 'application/json',
Authorization: regex({
generate: 'Bearer eyJhbGci...',
matcher: '^Bearer .+$',
}),
},
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
orderId: like('ord_123'), // any string
status: regex({
generate: 'shipped',
matcher: '^(pending|processing|shipped|delivered|cancelled)$',
}),
items: eachLike({
sku: like('PROD-1'),
quantity: like(2),
price: like(29.99),
}),
totalAmount: like(59.98), // must exist, must be a number
},
})
.executeTest(async (mockServer) => {
const client = new OrderServiceClient(mockServer.url);
const order = await client.getOrder('ord_123', 'Bearer eyJhbGci...');
expect(order.orderId).toBeDefined();
expect(order.totalAmount).toBeGreaterThan(0);
expect(order.items).toHaveLength(1);
});
});
it('returns 404 when order does not exist', async () => {
await provider
.given('order ord_999 does not exist')
.uponReceiving('a request for a non-existent order')
.withRequest({
method: 'GET',
path: '/orders/ord_999',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 404,
body: {
error: like('Order not found'),
code: like('ORDER_NOT_FOUND'),
},
})
.executeTest(async (mockServer) => {
const client = new OrderServiceClient(mockServer.url);
await expect(client.getOrder('ord_999')).rejects.toThrow('Order not found');
});
});
});
describe('POST /orders', () => {
it('creates a new order and returns the created resource', async () => {
await provider
.given('the user has items in cart')
.uponReceiving('a request to create an order')
.withRequest({
method: 'POST',
path: '/orders',
headers: { 'Content-Type': 'application/json' },
body: {
customerId: like('cust_456'),
items: eachLike({
sku: like('PROD-1'),
quantity: like(1),
}),
},
})
.willRespondWith({
status: 201,
headers: {
'Content-Type': 'application/json',
Location: regex({
generate: '/orders/ord_789',
matcher: '^/orders/[a-z0-9_]+$',
}),
},
body: {
orderId: like('ord_789'),
status: 'pending',
totalAmount: like(29.99),
},
})
.executeTest(async (mockServer) => {
const client = new OrderServiceClient(mockServer.url);
const result = await client.createOrder({
customerId: 'cust_456',
items: [{ sku: 'PROD-1', quantity: 1 }],
});
expect(result.orderId).toBeDefined();
expect(result.status).toBe('pending');
});
});
});
});Running this test generates a pact file at ./pacts/frontend-order-service.json. That file is the contract — a machine-readable record of every interaction the consumer relies on.
Verifying the Provider (Java Example)
The provider verification runs the recorded interactions against the real provider implementation. Here's the setup for a Spring Boot order-service using JUnit 5:
// OrderServiceProviderTest.java
@Provider("order-service")
@Consumer("frontend")
@PactBroker(
url = "${PACT_BROKER_URL}",
authentication = @PactBrokerAuth(
username = "${PACT_BROKER_USERNAME}",
password = "${PACT_BROKER_PASSWORD}"
)
)
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class OrderServiceProviderTest {
@LocalServerPort
private int port;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPacts(PactVerificationContext context) {
context.verifyInteraction();
}
// State handlers: set up test data matching consumer's "given" clauses
@State("an order with ID ord_123 exists")
public void setupOrderOrd123() {
Order order = Order.builder()
.id("ord_123")
.status(OrderStatus.SHIPPED)
.customerId("cust_456")
.items(List.of(
OrderItem.builder()
.sku("PROD-1")
.quantity(2)
.price(new BigDecimal("29.99"))
.build()
))
.totalAmount(new BigDecimal("59.98"))
.build();
orderRepository.save(order);
}
@State("order ord_999 does not exist")
public void setupMissingOrder() {
// Ensure this order does not exist
orderRepository.deleteById("ord_999");
}
@State("the user has items in cart")
public void setupCartState() {
// No specific database setup needed for POST test
}
}The @State methods are critical — they map the consumer's given() clause to actual test data setup on the provider side. When the Pact framework replays the interaction "a request for order details" with state "an order with ID ord_123 exists", it first calls setupOrderOrd123() to ensure the data exists.
Setting Up the Pact Broker
The Pact Broker is the hub that connects consumers and providers. It stores contracts, tracks verification results, and powers the can-i-deploy check that tells you whether a deployment is safe.
Deploy it with Docker Compose:
# docker-compose.pact-broker.yml
version: '3'
services:
postgres:
image: postgres:14
environment:
POSTGRES_DB: pact_broker
POSTGRES_USER: pact_broker
POSTGRES_PASSWORD: pact_broker_password
volumes:
- pact_broker_db:/var/lib/postgresql/data
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
depends_on:
- postgres
environment:
PACT_BROKER_DATABASE_URL: "postgres://pact_broker:pact_broker_password@postgres/pact_broker"
PACT_BROKER_LOG_LEVEL: INFO
PACT_BROKER_SQL_LOG_LEVEL: none
PACT_BROKER_BASE_URL: "https://pact-broker.your-domain.com"
volumes:
pact_broker_db:Use PactFlow (the hosted SaaS version) for teams that don't want to operate their own broker.
Publishing Contracts and CI/CD Integration
Publish pacts from the consumer's CI pipeline after tests pass:
# Using the Pact CLI
npx pact-broker publish ./pacts \
--broker-base-url=<span class="hljs-string">"$PACT_BROKER_URL" \
--broker-username=<span class="hljs-string">"$PACT_BROKER_USERNAME" \
--broker-password=<span class="hljs-string">"$PACT_BROKER_PASSWORD" \
--consumer-app-version=<span class="hljs-string">"$(git rev-parse --short HEAD)" \
--branch=<span class="hljs-string">"$(git branch --show-current)" \
--tag=<span class="hljs-string">"$(git branch --show-current)"The full CI pipeline for a consumer looks like this:
# .github/workflows/consumer-ci.yml
name: Frontend CI
on: [push, pull_request]
jobs:
test-and-publish-pacts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: npm ci
- name: Run contract tests
run: npm run test:contract
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
- name: Publish pacts to broker
run: |
npx pact-broker publish ./pacts \
--broker-base-url="$PACT_BROKER_URL" \
--broker-username="$PACT_BROKER_USERNAME" \
--broker-password="$PACT_BROKER_PASSWORD" \
--consumer-app-version="${GITHUB_SHA}" \
--branch="${GITHUB_REF_NAME}"
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_USERNAME: ${{ secrets.PACT_BROKER_USERNAME }}
PACT_BROKER_PASSWORD: ${{ secrets.PACT_BROKER_PASSWORD }}
- name: Can I deploy?
run: |
npx pact-broker can-i-deploy \
--pacticipant="frontend" \
--version="${GITHUB_SHA}" \
--to-environment="production" \
--broker-base-url="$PACT_BROKER_URL"
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}And the provider pipeline:
# .github/workflows/provider-ci.yml
name: Order Service CI
on: [push, pull_request]
jobs:
verify-pacts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Verify pacts from broker
run: ./mvnw test -Dtest=OrderServiceProviderTest
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_USERNAME: ${{ secrets.PACT_BROKER_USERNAME }}
PACT_BROKER_PASSWORD: ${{ secrets.PACT_BROKER_PASSWORD }}
# Publish verification results back to broker
PACT_VERIFIER_PUBLISH_RESULTS: true
GIT_COMMIT: ${{ github.sha }}
GIT_BRANCH: ${{ github.ref_name }}
- name: Can I deploy?
run: |
npx pact-broker can-i-deploy \
--pacticipant="order-service" \
--version="${GITHUB_SHA}" \
--to-environment="production" \
--broker-base-url="$PACT_BROKER_URL"The can-i-deploy command is the safety gate: it checks whether all consumers that the provider serves have verified against the current provider version. If any consumer's pact hasn't been verified, the deployment is blocked.
Common Pitfalls and How to Avoid Them
Overly strict matchers — Using exact value matches ('ord_123' instead of like('ord_123')) makes contracts brittle. The consumer usually doesn't care about the exact value, only its type and format. Use like(), eachLike(), and regex() matchers generously.
Missing state handlers — If a provider verification fails because @State("an order with ID ord_123 exists") has no matching handler, the Pact framework uses whatever's in the database — which might be nothing. Every given() clause in consumer tests needs a corresponding @State handler on the provider side.
Not publishing verification results — The can-i-deploy check only works if both sides publish their results to the broker. Consumers publish pacts; providers must publish verification results with PACT_VERIFIER_PUBLISH_RESULTS=true.
Testing internal implementation details — Contract tests cover the API boundary, not the provider's internal logic. If your consumer contract specifies fields that are only there for another consumer's benefit (not yours), you're over-specifying and will get false failures on refactors.
When Contract Testing Makes the Most Sense
Contract testing delivers maximum value when:
- Multiple teams own different services and can't easily coordinate integration environments
- Your services have frequent, independent deployments
- Schema changes are a common source of production incidents
- You have more than 3-4 services in the dependency graph
It's less valuable when services are tightly coupled and deployed together, or when you have a full integration environment that's cheap to run and fast to provision.
Start with one high-value consumer-provider pair — ideally the one where schema mismatches have caused incidents. Get the workflow down, see the value, then expand. Contract testing compounds: the more of your service graph it covers, the safer independent deployments become.