Contract Testing for Microservices: Consumer-Driven Contracts with Pact
When teams develop microservices independently, the biggest risk is one team changing an API that another team depends on. Contract testing solves this — without requiring a shared test environment or coordinating release schedules.
What Is Contract Testing?
A contract is a formal specification of the interactions between two services: what the consumer expects to receive, and what the provider agrees to deliver.
Consumer-driven contracts flip the traditional integration testing model. Instead of the provider publishing docs that consumers adapt to, consumers define exactly what they need, and providers verify they meet those needs.
This means:
- Consumers can test against a mock without a live provider
- Providers know exactly who depends on which parts of their API
- Breaking changes are caught in CI, before any service is deployed
Pact: The Standard for Contract Testing
Pact is the most widely-used contract testing framework. It supports most languages (JavaScript, Java, Python, Go, Ruby, .NET) and works with REST, GraphQL, and message-based systems.
How Pact Works
- The consumer writes a test that defines what it expects from the provider
- Pact generates a pact file (JSON) from that test
- The pact file is published to a Pact Broker (shared registry)
- The provider runs verification tests using the pact file
- If verification passes, both teams know the integration is safe
Consumer Test Example
// order-service/tests/payment-service.pact.spec.js
const { Pact } = require('@pact-foundation/pact');
const provider = new Pact({
consumer: 'order-service',
provider: 'payment-service',
port: 4000,
log: './logs/pact.log',
dir: './pacts',
});
describe('Payment Service', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
describe('charging a card', () => {
beforeEach(() => {
return provider.addInteraction({
state: 'a valid payment method exists for customer abc',
uponReceiving: 'a charge request',
withRequest: {
method: 'POST',
path: '/charges',
headers: { 'Content-Type': 'application/json' },
body: {
customerId: 'abc',
amount: like(9999),
currency: 'usd'
}
},
willRespondWith: {
status: 200,
body: {
id: like('ch_123'),
status: 'succeeded',
amount: like(9999)
}
}
});
});
it('charges the customer', async () => {
const paymentClient = new PaymentClient('http://localhost:4000');
const result = await paymentClient.charge('abc', 9999, 'usd');
expect(result.status).toBe('succeeded');
expect(result.amount).toBe(9999);
});
});
});The like() matcher says "I don't care about the exact value, just the type." This prevents brittle tests that break when test data changes.
Provider Verification Example
// payment-service/tests/pact-verification.spec.js
const { Verifier } = require('@pact-foundation/pact');
describe('Pact Verification', () => {
it('validates the expectations of order-service', () => {
return new Verifier({
provider: 'payment-service',
providerBaseUrl: 'http://localhost:3001',
pactBrokerUrl: 'https://your-pact-broker.com',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
stateHandlers: {
'a valid payment method exists for customer abc': async () => {
// Set up test data so this state is true
await db.customers.upsert({
id: 'abc',
paymentMethodId: 'pm_valid'
});
}
}
}).verifyProvider();
});
});The stateHandlers set up the database state the consumer test assumes. This is how provider tests stay realistic without sharing a database with the consumer.
Pact Broker: The Registry
The Pact Broker stores all contracts and verification results. You can use:
- PactFlow — hosted service from the Pact team, free for small teams
- Self-hosted — open-source broker you run yourself
# Publish pact from consumer CI
npx pact-broker publish ./pacts \
--broker-base-url https://your-pactflow.io \
--broker-token <span class="hljs-variable">$PACT_TOKEN \
--consumer-app-version <span class="hljs-variable">$GIT_COMMIT \
--branch <span class="hljs-variable">$BRANCH_NAMEThe broker tracks which versions of each service are compatible. Before deploying, check:
# Can I deploy order-service@abc123 to production?
npx pact-broker can-i-deploy \
--pacticipant order-service \
--version <span class="hljs-variable">$GIT_COMMIT \
--to-environment production \
--broker-base-url https://your-pactflow.ioIf any consumer pact fails against the version you're deploying, can-i-deploy returns exit code 1 and blocks the deployment.
Matching Rules
Pact's matching rules let you write flexible contracts that don't break on irrelevant changes.
Type Matchers
import { like, eachLike, term, integer, decimal } from '@pact-foundation/pact/src/dsl/matchers';
willRespondWith: {
status: 200,
body: {
id: like('abc-123'), // any string
count: integer(5), // any integer
price: decimal(9.99), // any decimal
status: term({ // matches regex
generate: 'active',
matcher: '^(active|inactive|pending)$'
}),
items: eachLike({ // array with at least one item
name: like('widget')
})
}
}When to Use Exact Values
Use exact values only when the specific value matters to the consumer's business logic:
// Good: exact status code matters
status: 200
// Good: exact enum value drives consumer behavior
paymentStatus: 'succeeded' // consumer branches on this
// Bad: exact dynamic ID doesn't matter
orderId: '4f8a2b' // use like('4f8a2b') insteadMessage Contract Testing
Pact also supports asynchronous messaging (Kafka, SNS, RabbitMQ):
// Consumer: order-service expects this event from inventory-service
messagePact
.given('inventory available for product abc')
.expectsToReceive('inventory reserved event')
.withContent({
event: 'inventory.reserved',
productId: like('abc'),
quantity: integer(1),
reservationId: like('res-123')
})
.verify(async (message) => {
// Simulate processing the message
await orderService.handleInventoryReserved(message);
// Assert the side effects
const order = await Order.findByReservation(message.reservationId);
expect(order.status).toBe('confirmed');
});GraphQL Contract Testing
For GraphQL APIs, use Pact's GraphQL body matching:
import { GraphQLInteraction, Matchers } from '@pact-foundation/pact';
const graphqlQuery = new GraphQLInteraction()
.uponReceiving('a query for user details')
.withRequest({ path: '/graphql', method: 'POST' })
.withQuery(
`query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}`
)
.withVariables({ id: '123' })
.willRespondWith({
status: 200,
body: {
data: {
user: {
id: Matchers.like('123'),
name: Matchers.like('Alice'),
email: Matchers.like('alice@example.com')
}
}
}
});CI/CD Integration
Consumer Pipeline
# .github/workflows/consumer.yml
- name: Run consumer pact tests
run: npm test
- name: Publish pacts to broker
run: |
npx pact-broker publish ./pacts \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_TOKEN \
--consumer-app-version ${{ github.sha }} \
--branch ${{ github.ref_name }}Provider Pipeline
# .github/workflows/provider.yml
- name: Verify pacts from broker
env:
GIT_COMMIT: ${{ github.sha }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_TOKEN }}
run: npm run test:pact
- name: Check can-i-deploy
run: |
npx pact-broker can-i-deploy \
--pacticipant payment-service \
--version ${{ github.sha }} \
--to-environment productionCommon Mistakes
Testing provider implementation details. Contracts should describe what the consumer needs, not every field the provider happens to return. Keep contracts minimal.
Not using state handlers. If you skip state handlers, provider tests run against whatever data happens to be in the test database. This causes flaky tests that pass sometimes and fail others.
Duplicating unit test coverage. Don't test all business logic in contract tests. Contract tests verify the API shape, not every business rule.
Ignoring the can-i-deploy check. Publishing verification results is useless if you don't gate deployments on can-i-deploy. Both steps are required.
Letting pacts get stale. When consumers stop using an endpoint, remove the interaction from the pact. Stale contracts create false confidence and slow down provider verification.
When Contract Testing Isn't Enough
Contract testing covers the shape and protocol of communication. It doesn't test:
- Actual business logic correctness
- Performance under load
- Data consistency across services
- Third-party service behavior
You still need integration tests and E2E tests for these. Contract testing reduces how many integration tests you need by catching API mismatches early.
Contract testing is most valuable when multiple teams own different services and deploy independently. If you have a single team managing all services, the overhead may not be worth it — shared integration tests might be simpler.
For teams at scale, contract testing is often the single highest-ROI testing investment in the microservices stack.