API Contract Testing with Pact — Consumer-Driven Contracts for Microservices
The integration testing problem#
You have 15 microservices. Service A calls Service B. You write an integration test that spins up both services, a database, and a message broker. The test takes 4 minutes to run. It breaks when Service B changes its response format. It breaks when the test database has stale data. It breaks when Docker is being Docker.
Now multiply that by every service pair. You have a test suite that takes 45 minutes and fails randomly.
Contract testing solves this by verifying that services agree on the shape of their interactions — without requiring both services to be running at the same time.
What is a contract?#
A contract is a formal agreement between two services about the structure of their API communication:
- Request format — HTTP method, path, headers, body shape
- Response format — status code, headers, body shape
- Interaction context — "given the user exists, when I request GET /users/123, I expect a 200 with these fields"
The contract lives in a file (a Pact file in JSON format) and is versioned alongside the code.
Consumer-driven contracts#
In consumer-driven contract testing, the consumer (the service making the request) defines the contract. The provider (the service handling the request) verifies it.
Why consumer-driven? Because the consumer knows what it actually needs. If the provider returns 50 fields but the consumer only uses 3, the contract only covers those 3. The provider is free to change the other 47 without breaking anything.
The workflow#
- Consumer writes a test that describes the interaction it expects
- Pact generates a contract file (the "pact") from that test
- The pact is shared with the provider (via Pact Broker or file)
- Provider runs verification — replays the interactions against its real API
- If verification passes, both services are compatible
The Pact framework#
Pact is the most widely adopted contract testing framework. It supports JavaScript, Java, Python, Go, Ruby, .NET, and more.
Consumer-side test (JavaScript)#
const { PactV3 } = require('@pact-foundation/pact');
const provider = new PactV3({
consumer: 'OrderService',
provider: 'UserService',
});
describe('User API contract', () => {
it('returns user details', async () => {
// Define the expected interaction
provider
.given('user 123 exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/users/123',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 123,
name: 'Alice',
email: 'alice@example.com',
},
});
await provider.executeTest(async (mockServer) => {
// Call your real consumer code against the mock
const user = await fetchUser(mockServer.url, 123);
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
});
});
});
When this test runs, Pact:
- Starts a mock provider server
- Registers the expected interaction
- Your consumer code makes the real HTTP call to the mock
- Pact verifies the consumer made the expected request
- Generates a pact JSON file with the recorded interaction
The generated pact file#
{
"consumer": { "name": "OrderService" },
"provider": { "name": "UserService" },
"interactions": [
{
"description": "a request for user 123",
"providerState": "user 123 exists",
"request": {
"method": "GET",
"path": "/users/123",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"id": 123,
"name": "Alice",
"email": "alice@example.com"
}
}
}
]
}
Provider-side verification (JavaScript)#
const { Verifier } = require('@pact-foundation/pact');
describe('User API provider verification', () => {
it('validates the OrderService contract', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pact-broker.internal',
provider: 'UserService',
providerVersion: process.env.GIT_SHA,
publishVerificationResult: true,
stateHandlers: {
'user 123 exists': async () => {
// Set up the provider state — seed the database
await db.users.create({ id: 123, name: 'Alice', email: 'alice@example.com' });
},
},
});
await verifier.verifyProvider();
});
});
The verifier:
- Fetches pacts from the broker for this provider
- For each interaction, calls the state handler to set up test data
- Replays the request against the real provider API
- Compares the actual response to the expected contract
- Publishes the verification result
The Pact Broker#
The Pact Broker is the central hub for sharing and managing contracts.
What it does#
- Stores pacts published by consumers
- Stores verification results published by providers
- Tracks versions — which consumer version works with which provider version
- Provides the can-i-deploy tool — answers "is it safe to deploy this version?"
- Visualizes dependencies — shows a network graph of service relationships
Publishing pacts to the broker#
pact-broker publish ./pacts \
--consumer-app-version=$(git rev-parse HEAD) \
--branch=$(git branch --show-current) \
--broker-base-url=https://pact-broker.internal
can-i-deploy#
The most valuable feature of the Pact Broker. Before deploying a service, ask:
pact-broker can-i-deploy \
--pacticipant=OrderService \
--version=$(git rev-parse HEAD) \
--to-environment=production
This checks:
- Have all contracts for this version been verified?
- Did all verifications pass?
- Are the verified provider versions currently deployed in the target environment?
If any check fails, the deployment is blocked. No broken contracts reach production.
CI/CD integration#
Consumer pipeline#
# .github/workflows/consumer.yml
name: Consumer CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test # Runs Pact consumer tests, generates pact files
- name: Publish pacts
run: |
npx pact-broker publish ./pacts \
--consumer-app-version=${{ github.sha }} \
--branch=${{ github.ref_name }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }}
- name: Can I deploy?
run: |
npx pact-broker can-i-deploy \
--pacticipant=OrderService \
--version=${{ github.sha }} \
--to-environment=production
Provider pipeline#
# .github/workflows/provider.yml
name: Provider CI
on: [push]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run start:test & # Start provider in test mode
- name: Verify contracts
run: npm run test:pact # Runs provider verification
env:
GIT_SHA: ${{ github.sha }}
- name: Can I deploy?
run: |
npx pact-broker can-i-deploy \
--pacticipant=UserService \
--version=${{ github.sha }} \
--to-environment=production
Webhook-triggered verification#
When a consumer publishes a new pact, the broker can trigger the provider's CI pipeline via webhook. This ensures providers verify new contracts immediately, not just on their own push cycle.
Contract testing vs. integration testing#
| Aspect | Contract Testing | Integration Testing |
|---|---|---|
| Services running | One at a time | All involved services |
| Speed | Seconds | Minutes |
| Flakiness | Very low | High (network, state, timing) |
| What it catches | Schema mismatches, missing fields | Logic bugs, data flow issues |
| Scope | API boundary | End-to-end behavior |
| Maintenance | Low | High (test environments, data) |
Contract testing does not replace integration testing. It replaces the subset of integration tests that exist only to verify "does Service A still talk to Service B correctly?"
Use contract tests for API shape compatibility. Use integration tests (sparingly) for business logic that spans services.
Provider states#
Provider states are the setup mechanism for contract tests. They tell the provider what data to have ready before replaying an interaction.
stateHandlers: {
'user 123 exists': async () => {
await db.users.create({ id: 123, name: 'Alice', email: 'alice@example.com' });
},
'no users exist': async () => {
await db.users.deleteAll();
},
'user 123 has 3 orders': async () => {
await db.users.create({ id: 123, name: 'Alice', email: 'alice@example.com' });
await db.orders.createMany([
{ userId: 123, total: 50 },
{ userId: 123, total: 75 },
{ userId: 123, total: 120 },
]);
},
}
Keep provider states focused and minimal. They should set up just enough data for the interaction, not replicate your entire production database.
Common pitfalls#
- Over-specifying contracts — asserting on every field when you only use three. Use matchers (like, eachLike, regex) instead of exact values.
- Ignoring provider states — skipping state setup leads to flaky provider verification
- Not publishing verification results — can-i-deploy only works if both sides publish
- Testing internal implementation — contracts should test the API boundary, not how the provider builds the response
- Forgetting webhooks — without them, providers only verify on their own push cycle, creating gaps
Key takeaways#
- Consumer-driven contracts let the consumer define what it needs — providers verify they can deliver
- Pact generates contract files from consumer tests and verifies them against the real provider
- The Pact Broker stores contracts, tracks versions, and powers can-i-deploy
- can-i-deploy is the deployment gate — it prevents incompatible versions from reaching production
- Contract tests run in seconds with zero flakiness — they replace the "does it still talk correctly" integration tests
- Provider states set up test data for each interaction — keep them minimal
- Combine both — contract tests for API compatibility, integration tests for cross-service business logic
Article #421 in the Codelit engineering series. Explore our full library of system design, infrastructure, and architecture guides at codelit.io.
Try it on Codelit
GitHub Integration
Paste a repo URL and generate architecture from your actual codebase
Related articles
Try these templates
OpenAI API Request Pipeline
7-stage pipeline from API call to token generation, handling millions of requests per minute.
8 componentsScalable SaaS Application
Modern SaaS with microservices, event-driven processing, and multi-tenant architecture.
10 componentsDistributed Rate Limiter
API rate limiting with sliding window, token bucket, and per-user quotas.
7 componentsBuild this architecture
Generate an interactive architecture for API Contract Testing with Pact in seconds.
Try it in Codelit →
Comments