You have two services. Service A calls Service B. Both have tests. Both tests pass. You deploy Service B with a schema change. Service A breaks in production.
This is the integration gap. Unit tests don’t catch it (they mock the dependency). E2E tests might catch it (if they run, and if they’re not flaky). Contract tests always catch it — before deployment.
What contract testing is
A contract test verifies that two services agree on the shape of their interaction — the request format, the response format, the status codes, the headers. Not that the business logic is correct. Just that both sides speak the same language.
Consumer-driven contract testing means the consumer (the service making the call) defines the contract. The provider (the service receiving the call) verifies it. If the provider changes in a way that breaks the consumer’s expectations, the provider’s build fails.
This inverts the typical dynamic: instead of the provider publishing a spec and hoping consumers conform, the consumers declare what they need, and the provider proves it still delivers.
Pact: the tool
Pact is the most widely adopted contract testing framework. It supports multiple languages (JavaScript, Java, .NET, Go, Python, Ruby) and works with both REST and message-based interactions.
The consumer side
The consumer writes a test that describes the interaction it expects:
// consumer.pact.test.js
const { Pact } = require('@pact-foundation/pact');
const path = require('path');
const provider = new Pact({
consumer: 'OrderService',
provider: 'UserService',
port: 1234,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('UserService consumer', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
it('fetches a user by ID', async () => {
await provider.addInteraction({
state: 'a user with ID 42 exists',
uponReceiving: 'a request for user 42',
withRequest: {
method: 'GET',
path: '/api/users/42',
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 42,
name: 'Sven Thirion',
email: 'sven@example.com',
},
},
});
const response = await fetchUser(provider.mockService.baseUrl, 42);
expect(response.id).toBe(42);
expect(response.name).toBe('Sven Thirion');
});
});
This test generates a pact file — a JSON contract describing the expected interaction. The consumer doesn’t need the real provider running.
The provider side
The provider runs verification against the pact file:
// provider.pact.test.js
const { Verifier } = require('@pact-foundation/pact');
new Verifier({
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://your-broker.pactflow.io',
provider: 'UserService',
providerVersion: process.env.GIT_SHA,
publishVerificationResult: true,
stateHandlers: {
'a user with ID 42 exists': async () => {
await seedUser({ id: 42, name: 'Sven Thirion', email: 'sven@example.com' });
},
},
}).verifyProvider();
If the provider’s actual response doesn’t match what the consumer expects — different field names, missing fields, wrong types, different status code — the verification fails and the provider can’t deploy.
The Pact Broker: the missing piece
Without a broker, pact files are just JSON files you have to manually share between repos. The Pact Broker (self-hosted or PactFlow SaaS) solves this:
What it does
- Stores pact files centrally — consumers publish, providers fetch
- Tracks verification results — which provider version verified which consumer version
- Generates a network diagram showing all service dependencies
- Enables
can-i-deploy— a CLI command that answers “is it safe to deploy this version?” by checking all contracts are verified - Manages environments — tracks which versions are in dev, staging, production
The can-i-deploy check
This is the killer feature. Before deploying any service, run:
pact-broker can-i-deploy \
--pacticipant UserService \
--version $(git rev-parse HEAD) \
--to-environment production
This checks: “has every consumer that talks to UserService verified against this version?” If not, you can’t deploy. No broken integrations in production.
Setting up a self-hosted Pact Broker
# docker-compose.yml
services:
pact-broker:
image: pactfoundation/pact-broker
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres/pact
PACT_BROKER_BASE_URL: http://localhost:9292
depends_on:
- postgres
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact
volumes:
- pact-data:/var/lib/postgresql/data
volumes:
pact-data:
Run docker compose up -d and you have a Pact Broker at http://localhost:9292.
Where contract tests fit in the pyramid
Contract tests sit between unit tests and integration tests on the testing pyramid:
They’re faster than integration tests (no real service calls — consumer tests use a mock, provider tests use a local server). They’re more targeted than E2E tests (they test one interaction, not a full user flow). And they provide a guarantee that integration tests don’t: they prevent deployment of breaking changes.
When to use contract testing
- Microservices — any service-to-service communication over HTTP or messaging
- Frontend ↔ Backend — especially when teams deploy independently
- Third-party API consumers — verify your assumptions about external APIs
- Event-driven architectures — Pact supports message-based contracts (Kafka, RabbitMQ, SNS/SQS)
When NOT to use contract testing
- Monoliths — if everything deploys together, integration tests cover this
- Database schemas — contract testing is for service interfaces, not storage
- Business logic validation — contracts verify shape, not correctness
The CI/CD integration
The full loop:
- Consumer PR runs consumer pact tests → publishes pact to broker
- Broker triggers provider verification (webhook or scheduled)
- Provider verifies → publishes result to broker
- Before any deployment:
can-i-deploygate in the pipeline - After deployment:
pact-broker record-deployment --environment production
This gives you a continuous, automated answer to “will this deploy break anyone?”
That’s not testing. That’s engineering confidence into the system.