Advanced Consumer-Driven Contract Testing with Pact for Microservices
Consumer-driven contract testing with Pact is well-documented at the basics level: consumer writes expectations, provider verifies them. But real multi-team microservices environments hit edge cases that basic tutorials don't cover. This guide focuses on the advanced patterns: provider states, message contracts, Pact Broker integration, and making can-i-deploy part of your release gate.
The Problem Pact Solves at Scale
When 10 services communicate with each other, integration testing becomes a combinatorial nightmare. Testing service A requires service B which requires service C — the dependency chain never ends.
Contract testing breaks this by replacing integration tests with:
- A consumer pact that records what the consumer needs from the provider
- A provider verification that the provider satisfies those pacts
Neither side needs to run the other's service. Teams deploy independently, and Pact Broker tracks which versions are compatible.
Advanced Provider States
Provider states are setup functions that configure the provider's data before each interaction is verified. Basic examples use simple strings. Advanced usage requires thinking carefully about state isolation and state cleanup.
Parameterized Provider States
Pass parameters to provider states for data-driven setup:
Consumer pact with state parameters:
// order-service/tests/contract/inventory.pact.test.js
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, integer, string } = MatchersV3;
const provider = new PactV3({
consumer: 'OrderService',
provider: 'InventoryService',
dir: './pacts',
});
it('checks stock for a specific product', () => {
return provider
.given('product exists with stock', {
product_id: 'prod-xyz', // Parameters passed to provider state
quantity: 50,
})
.uponReceiving('a stock check for product prod-xyz')
.withRequest({
method: 'GET',
path: '/stock/prod-xyz',
})
.willRespondWith({
status: 200,
body: {
product_id: string('prod-xyz'),
available: integer(50),
reserved: integer(0),
},
})
.executeTest(async (mockServer) => {
const client = new InventoryClient(mockServer.url);
const stock = await client.getStock('prod-xyz');
expect(stock.available).toBe(50);
});
});Provider state handler with parameters:
// inventory-service/tests/contract/verify.test.js
const { Verifier } = require('@pact-foundation/pact');
const { db, seedData } = require('../helpers/test-db');
describe('Pact Verification', () => {
it('validates OrderService pacts', () => {
return new Verifier({
provider: 'InventoryService',
providerBaseUrl: 'http://localhost:3002',
pactBrokerUrl: process.env.PACT_BROKER_URL,
stateHandlers: {
'product exists with stock': async (parameters) => {
// parameters = { product_id: 'prod-xyz', quantity: 50 }
await db.query(
'INSERT INTO products (id, available_quantity) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET available_quantity = $2',
[parameters.product_id, parameters.quantity]
);
},
'product does not exist': async (parameters) => {
await db.query(
'DELETE FROM products WHERE id = $1',
[parameters.product_id]
);
},
'product is out of stock': async (parameters) => {
await db.query(
'INSERT INTO products (id, available_quantity) VALUES ($1, 0) ON CONFLICT (id) DO UPDATE SET available_quantity = 0',
[parameters.product_id]
);
},
},
// Teardown between interactions
afterEach: async () => {
await db.query('TRUNCATE products CASCADE');
},
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT || '1.0.0',
providerVersionBranch: process.env.GIT_BRANCH || 'main',
}).verifyProvider();
});
});State Setup vs State Teardown
A common mistake is not cleaning up state between interactions. If pact A sets up a product and pact B assumes no products exist, B will fail:
stateHandlers: {
'no products exist': async () => {
await db.query('TRUNCATE products');
},
'multiple products exist': async () => {
await db.query('TRUNCATE products'); // Always clean first
await db.query(`
INSERT INTO products (id, name, price) VALUES
('prod-001', 'Widget', 9.99),
('prod-002', 'Gadget', 19.99),
('prod-003', 'Doohickey', 4.99)
`);
},
},
// Or use beforeEach/afterEach for automatic cleanup
beforeEach: async () => {
await db.query('BEGIN'); // Start transaction
},
afterEach: async () => {
await db.query('ROLLBACK'); // Roll back all changes
},Message Contract Testing
HTTP request/response contracts are well covered. Message contracts (for event-driven services) are less common but equally important.
Consumer Side: Asserting Message Structure
// notification-service/tests/contract/order-events.pact.test.js
const { PactV3, MatchersV3, MessageConsumerPact } = require('@pact-foundation/pact');
const { like, string, integer, timestamp } = MatchersV3;
const path = require('path');
const messagePact = new MessageConsumerPact({
consumer: 'NotificationService',
provider: 'OrderService',
dir: path.resolve('./pacts'),
});
describe('Order event contract', () => {
it('handles order.confirmed events', async () => {
await messagePact
.given('an order has been confirmed')
.expectsToReceive('an order.confirmed event')
.withContent({
event_type: string('order.confirmed'),
order_id: like('ord-123'),
customer_id: like('cust-456'),
total: like(99.99),
items: [
{
product_id: like('prod-789'),
quantity: integer(2),
price: like(49.99),
},
],
confirmed_at: timestamp("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
})
.withMetadata({ contentType: 'application/json' })
.verify(async (message) => {
// This is the actual consumer code that processes the message
const notificationService = new NotificationService();
const result = await notificationService.handleOrderConfirmed(message.contents);
expect(result.notificationSent).toBe(true);
expect(result.channel).toBe('email');
});
});
it('handles order.cancelled events', async () => {
await messagePact
.given('an order has been cancelled')
.expectsToReceive('an order.cancelled event')
.withContent({
event_type: string('order.cancelled'),
order_id: like('ord-123'),
customer_id: like('cust-456'),
reason: like('customer request'),
cancelled_at: timestamp("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
})
.verify(async (message) => {
const notificationService = new NotificationService();
const result = await notificationService.handleOrderCancelled(message.contents);
expect(result.notificationSent).toBe(true);
expect(result.refundInitiated).toBe(true);
});
});
});Provider Side: Publishing Messages That Satisfy Contracts
// order-service/tests/contract/verify-message-pacts.test.js
const { MessageProviderPact } = require('@pact-foundation/pact');
const { EventEmitter } = require('../../src/events/event-emitter');
describe('Message Pact Verification', () => {
it('satisfies NotificationService message contracts', async () => {
const pact = new MessageProviderPact({
messageProviders: {
'an order has been confirmed': async () => {
// Return the actual message your service produces
return {
contents: {
event_type: 'order.confirmed',
order_id: 'ord-test-001',
customer_id: 'cust-test-001',
total: 99.99,
items: [
{ product_id: 'prod-test-001', quantity: 2, price: 49.99 },
],
confirmed_at: new Date().toISOString(),
},
metadata: { contentType: 'application/json' },
};
},
'an order has been cancelled': async () => {
return {
contents: {
event_type: 'order.cancelled',
order_id: 'ord-test-002',
customer_id: 'cust-test-002',
reason: 'customer request',
cancelled_at: new Date().toISOString(),
},
metadata: { contentType: 'application/json' },
};
},
},
provider: 'OrderService',
pactBrokerUrl: process.env.PACT_BROKER_URL,
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT || '1.0.0',
});
return pact.verify();
});
});Pact Broker Setup and Workflow
The Pact Broker is the central store for pacts and verification results. Without it, sharing pacts across teams is manual and error-prone.
Running Pact Broker Locally
# docker-compose.pact.yml
version: '3'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres/pact
depends_on:
- postgresdocker-compose -f docker-compose.pact.yml up -dAccess the Pact Broker UI at http://localhost:9292.
Publishing Pacts from CI
# In consumer CI pipeline
pact-broker publish ./pacts \
--consumer-app-version "$GIT_COMMIT" \
--branch "$GIT_BRANCH" \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"Verifying Against Broker in CI
# In provider CI pipeline
pact-broker verify \
--provider "InventoryService" \
--provider-base-url "http://localhost:3002" \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN" \
--provider-version "$GIT_COMMIT" \
--provider-version-branch "$GIT_BRANCH" \
--publish-verification-resultscan-i-deploy: The Release Gate
can-i-deploy is the tool that makes contract testing actionable. Before deploying a service, check whether the version you're about to deploy is compatible with all versions currently in production.
# Before deploying order-service v2.3.0 to production:
pact-broker can-i-deploy \
--pacticipant "OrderService" \
--version "$GIT_COMMIT" \
--to-environment production \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"
# Output if safe:
# Computer says yes ✔
#
# CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS?
# OrderService | abc123 | InventoryService | def456 | true
# OrderService | abc123 | PaymentService | ghi789 | true
# OrderService | abc123 | NotificationSvc | jkl012 | true
# Output if unsafe:
# Computer says no ✗
#
# CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS?
# OrderService | abc123 | PaymentService | ghi789 | false
#
# The verification for OrderService/abc123 and PaymentService/ghi789 has failedIntegrate into your deployment pipeline:
# .github/workflows/deploy.yml
jobs:
deploy:
steps:
- name: Check pact compatibility
run: |
pact-broker can-i-deploy \
--pacticipant "$SERVICE_NAME" \
--version "$GITHUB_SHA" \
--to-environment production \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"
- name: Deploy to production
if: success()
run: kubectl apply -f k8s/production/If can-i-deploy fails, the deployment step is skipped — no unsafe deployment possible.
Multi-Consumer Provider Verification
When multiple consumers use the same provider, verify all of them in CI:
// user-service/tests/contract/verify-all-consumers.test.js
const { Verifier } = require('@pact-foundation/pact');
describe('User Service - All Consumer Verifications', () => {
it('satisfies all consumer contracts from broker', async () => {
return new Verifier({
provider: 'UserService',
providerBaseUrl: 'http://localhost:3003',
// Pull ALL pacts from broker, not just one consumer
pactBrokerUrl: process.env.PACT_BROKER_URL,
consumerVersionSelectors: [
{ mainBranch: true }, // latest main branch from each consumer
{ deployedOrReleased: true }, // currently deployed versions
],
stateHandlers: { /* ... */ },
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
providerVersionBranch: process.env.GIT_BRANCH,
}).verifyProvider();
});
});This single verification run covers:
- All consumers that have pacts in the broker
- Both their current main branch version AND whatever is deployed in production
Handling Pact Failures in Team Workflows
When a provider verification fails, the right action depends on who made the breaking change.
Provider broke a consumer's contract:
- Provider team checks which consumer pact failed
- Either revert the breaking change or negotiate a new contract with the consumer team
- Consumer team updates their pact if the API change was intentional
Consumer changed their expectations:
- Consumer team publishes a new pact version
- Provider team runs verification against the new pact
- If the new expectations can't be satisfied, negotiate
Pending pacts (new consumer not yet deployed):
enablePending: true, // Don't fail verification for pending pactsPending pacts allow a new consumer to publish pacts before the provider has verified them, without blocking the provider's CI.
Key Takeaways
- Use parameterized provider states for data-driven test setup — avoid hardcoded product IDs in pacts
- Always clean up state between interactions — use transaction rollback for fast isolation
- Write message contracts for event-driven services, not just HTTP APIs
- Set up Pact Broker early — sharing pact files by hand doesn't scale past 3 services
- Make
can-i-deploya hard gate in your deployment pipeline — it's the whole point of contract testing - Use pending pacts to allow new consumers to onboard without blocking providers
- Verify all consumer pact versions in provider CI — both main branch and currently deployed