Contract Testing with Pact: Test API Contracts Between Services
Integration tests are slow and brittle. Contract testing lets consumer and provider verify their API agreement independently — without spinning up the whole system.
In a microservices architecture, services talk to each other through APIs. When the user service changes a response field, the order service breaks. When the payment service adds a required field, deployments fail. These integration bugs are painful — and they usually only show up in staging or production.
Contract testing catches these mismatches earlier. Consumer and provider verify the same contract, independently, without spinning up the entire system.
The Problem With Integration Tests
Integration tests require all services to be running. In a system with dozens of microservices, that means:
- A full environment to spin up
- Slow test suites (minutes, not seconds)
- Flaky tests due to network issues and service availability
- No clear ownership of what broke when
And they still miss edge cases. A provider changing a field name silently breaks consumers that aren’t in the same test suite.
Contract Testing to the Rescue
In contract testing, the consumer defines what it expects from the provider. The provider verifies that it actually delivers that. No full environment needed.
Consumer team defines: "I call /users/123 and expect { id, name, email }"
Provider team verifies: "Does our /users/:id endpoint actually return that shape?"
The contract is the shared artifact. Pact is the most popular tool for this.
Consumer Side: Define the Contract
The consumer writes a test that describes the interaction it expects:
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserService } from '../services/user-service';
const { like, string } = MatchersV3;
describe('UserService contract', () => {
const provider = new PactV3({
consumer: 'OrderService',
provider: 'UserService',
dir: './pacts', // Where the contract file is saved
});
it('gets a user by id', async () => {
await provider
.addInteraction({
states: [{ description: 'user 123 exists' }],
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/users/123',
headers: { Authorization: like('Bearer token123') },
},
willRespondWith: {
status: 200,
body: {
id: string('123'),
name: string('Alice'),
email: string('[email protected]'),
},
},
})
.executeTest(async (mockProvider) => {
const userService = new UserService(mockProvider.url);
const user = await userService.getUser('123');
expect(user.id).toBe('123');
expect(user.name).toBeDefined();
expect(user.email).toBeDefined();
});
});
});
When this test runs, Pact:
- Starts a mock server that returns exactly what the consumer expects
- Records the interaction into a pact file (JSON) in
./pacts/
The pact file is the contract. It gets published to a Pact Broker (or committed to a repo).
Provider Side: Verify the Contract
The provider pulls the pact file and verifies their real implementation against it:
import { PactV3 } from '@pact-foundation/pact';
import { startServer } from '../app';
describe('UserService provider verification', () => {
let server: Server;
beforeAll(async () => {
server = await startServer(3001);
});
afterAll(() => server.close());
it('validates the contract from OrderService', async () => {
const options = {
provider: 'UserService',
providerBaseUrl: 'http://localhost:3001',
// Pull pacts from broker or local file
pactUrls: ['./pacts/OrderService-UserService.json'],
// Or from a Pact Broker:
// pactBrokerUrl: 'https://your-broker.pactflow.io',
// consumerVersionSelectors: [{ mainBranch: true }],
stateHandlers: {
'user 123 exists': async () => {
// Set up test data
await db.users.upsert({ id: '123', name: 'Alice', email: '[email protected]' });
},
},
};
await new PactV3(options).verifyProvider();
});
});
If the provider’s response doesn’t match the contract — wrong field name, missing field, wrong type — the test fails. The provider team knows before shipping.
The Pact Broker
Publishing pacts to a central broker (like PactFlow or a self-hosted Pact Broker) enables:
- Multiple consumers sharing the same provider
- Version tracking (“does provider v2.3 satisfy consumer v1.8?”)
- The
can-i-deploycheck in CI
# Consumer publishes pact after tests pass
pact-broker publish ./pacts \
--consumer-app-version "$GIT_SHA" \
--broker-base-url https://your-broker.pactflow.io
# Before deploying, check if it's safe
pact-broker can-i-deploy \
--pacticipant OrderService \
--version "$GIT_SHA" \
--to-environment production
This gives you deployment confidence without integration environments.
What Contract Tests Don’t Cover
Contract testing verifies the shape of the API, not the business logic. They check:
- ✅ Field names and types
- ✅ HTTP status codes
- ✅ Required vs optional fields
- ❌ Whether the backend logic is correct
- ❌ Performance
- ❌ Auth edge cases
You still need unit tests for business logic and a few smoke tests in staging.
When to Add Contract Testing
Contract testing pays off when:
- Multiple teams own different services
- API changes frequently break consumers
- You want to reduce reliance on shared staging environments
- You’re moving toward continuous deployment
It’s overkill for:
- A monolith with internal module boundaries
- A single team owning all services
- Simple, stable APIs
Key Takeaways
- Contract testing verifies API agreements between consumer and provider independently
- The consumer defines the contract; the provider verifies it
- Pact is the standard tool — use
PactV3for modern TypeScript setups - A Pact Broker stores pacts and enables
can-i-deploychecks - Contract tests don’t replace unit or smoke tests — they complement them