| name | contract-testing |
| description | Use when implementing Pact contracts, choosing consumer-driven vs provider-driven approaches, handling breaking API changes, setting up contract brokers, or preventing service integration issues - provides tool selection, anti-patterns, and workflow patterns |
Contract Testing
Overview
Core principle: Test the contract, not the implementation. Verify integration points independently.
Rule: Contract tests catch breaking changes before deployment, not in production.
Tool Selection Decision Tree
| Your Stack | Team Structure | Use | Why |
|---|---|---|---|
| Polyglot microservices | Multiple teams | Pact | Language-agnostic, mature broker |
| Java Spring ecosystem | Coordinated teams | Spring Cloud Contract | Spring integration, code-first |
| GraphQL APIs | Known consumers | Pact + GraphQL | Query validation |
| OpenAPI/REST | Public/many consumers | OpenAPI Spec Testing | Schema-first, documentation |
First choice: Pact (most mature ecosystem, widest language support)
Why contract testing: Catches API breaking changes in CI, not production. Teams test independently without running dependencies.
Contract Type Decision Framework
| Scenario | Approach | Tools |
|---|---|---|
| Internal microservices, known consumers | Consumer-Driven (CDC) | Pact, Spring Cloud Contract |
| Public API, many unknown consumers | Provider-Driven (Schema-First) | OpenAPI validation, Spectral |
| Both internal and external consumers | Bi-Directional | Pact + OpenAPI |
| Event-driven/async messaging | Message Pact | Pact (message provider/consumer) |
Default: Consumer-driven for internal services, schema-first for public APIs
Anti-Patterns Catalog
❌ Over-Specification
Symptom: Contract tests verify exact response format, including fields consumer doesn't use
Why bad: Brittle tests, provider can't evolve API, false positives
Fix: Only specify what consumer actually uses
// ❌ Bad - over-specified
.willRespondWith({
status: 200,
body: {
id: 123,
name: 'John',
email: 'john@example.com',
created_at: '2023-01-01',
updated_at: '2023-01-02',
phone: '555-1234',
address: {...} // Consumer doesn't use these
}
})
// ✅ Good - specify only what's used
.willRespondWith({
status: 200,
body: {
id: Matchers.integer(123),
name: Matchers.string('John')
}
})
❌ Testing Implementation Details
Symptom: Contract tests verify database queries, internal logic, or response timing
Why bad: Couples tests to implementation, not contract
Fix: Test only request/response contract, not how provider implements it
// ❌ Bad - testing implementation
expect(provider.database.queryCalled).toBe(true)
// ✅ Good - testing contract only
expect(response.status).toBe(200)
expect(response.body.name).toBe('John')
❌ Brittle Provider States
Symptom: Provider states hardcode IDs, dates, or specific data that changes
Why bad: Tests fail randomly, high maintenance
Fix: Use matchers, generate data in state setup
// ❌ Bad - hardcoded state
.given('user 123 exists')
.uponReceiving('request for user 123')
.withRequest({ path: '/users/123' })
// ✅ Good - flexible state
.given('a user exists')
.uponReceiving('request for user')
.withRequest({ path: Matchers.regex('/users/\\d+', '/users/123') })
.willRespondWith({
body: {
id: Matchers.integer(123),
name: Matchers.string('John')
}
})
❌ No Contract Versioning
Symptom: Breaking changes deployed without consumer coordination
Why bad: Runtime failures, production incidents
Fix: Use can-i-deploy, tag contracts by environment
# ✅ Good - check before deploying
pact-broker can-i-deploy \
--pacticipant UserService \
--version 2.0.0 \
--to production
❌ Missing Can-I-Deploy
Symptom: Deploying without checking if all consumers compatible
Why bad: Deploy provider changes that break consumers
Fix: Run can-i-deploy in CI before deployment
Pact Broker Workflow
Core workflow:
- Consumer: Write contract test → Generate pact file
- Consumer CI: Publish pact to broker with version tag
- Provider CI: Fetch contracts → Verify → Publish results
- Provider CD: Run can-i-deploy → Deploy if compatible
Publishing Contracts
# Consumer publishes pact with version and branch
pact-broker publish pacts/ \
--consumer-app-version ${GIT_SHA} \
--branch ${GIT_BRANCH} \
--tag ${ENV}
Verifying Contracts
// Provider verifies against broker
const { Verifier } = require('@pact-foundation/pact')
new Verifier({
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_URL,
provider: 'UserService',
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
consumerVersionSelectors: [
{ mainBranch: true }, // Latest from main
{ deployed: 'production' }, // Currently in production
{ deployed: 'staging' } // Currently in staging
]
}).verifyProvider()
Can-I-Deploy Check
# CI/CD pipeline (GitHub Actions example)
- name: Check if can deploy
run: |
pact-broker can-i-deploy \
--pacticipant UserService \
--version ${{ github.sha }} \
--to-environment production
Rule: Never deploy without can-i-deploy passing
Breaking Change Taxonomy
| Change Type | Breaking? | Migration Strategy |
|---|---|---|
| Add optional field | No | Deploy provider first |
| Add required field | Yes | Use expand/contract pattern |
| Remove field | Yes | Deprecate → verify no consumers use → remove |
| Change field type | Yes | Add new field → migrate consumers → remove old |
| Rename field | Yes | Add new → deprecate old → remove old |
| Change status code | Yes | Version API or expand responses |
Expand/Contract Pattern
For adding required field:
Expand (Week 1-2):
// Provider adds NEW field (optional), keeps OLD field
{
user_name: "John", // Old field (deprecated)
name: "John" // New field
}
Migrate (Week 3-4):
- Consumers update to use new field
- Update contracts
- Verify all consumers migrated
Contract (Week 5):
// Provider removes old field
{
name: "John" // Only new field remains
}
Provider State Patterns
Purpose: Set up test data before verification
Pattern: Use state handlers to create/clean up data
// Provider state setup
const { Verifier } = require('@pact-foundation/pact')
new Verifier({
stateHandlers: {
'a user exists': async () => {
// Setup: Create test user
await db.users.create({
id: 123,
name: 'John Doe'
})
},
'no users exist': async () => {
// Setup: Clear users
await db.users.deleteAll()
}
},
afterEach: async () => {
// Cleanup after each verification
await db.users.deleteAll()
}
}).verifyProvider()
Best practices:
- States should be independent
- Clean up after each verification
- Use transactions for database tests
- Don't hardcode IDs (use matchers)
Async/Event-Driven Messaging Contracts
For Kafka, RabbitMQ, SNS/SQS: Use Message Pact (different API than HTTP Pact)
Consumer Message Contract
const { MessageConsumerPact, MatchersV3 } = require('@pact-foundation/pact')
describe('User Event Consumer', () => {
const messagePact = new MessageConsumerPact({
consumer: 'NotificationService',
provider: 'UserService'
})
it('processes user created events', () => {
return messagePact
.expectsToReceive('user created event')
.withContent({
userId: MatchersV3.integer(123),
email: MatchersV3.string('user@example.com'),
eventType: 'USER_CREATED'
})
.withMetadata({
'content-type': 'application/json'
})
.verify((message) => {
processUserCreatedEvent(message.contents)
})
})
})
Provider Message Verification
// Provider verifies it can produce matching messages
const { MessageProviderPact } = require('@pact-foundation/pact')
describe('User Event Producer', () => {
it('publishes user created events matching contracts', () => {
return new MessageProviderPact({
messageProviders: {
'user created event': () => ({
contents: {
userId: 123,
email: 'test@example.com',
eventType: 'USER_CREATED'
},
metadata: {
'content-type': 'application/json'
}
})
}
}).verify()
})
})
Key Differences from HTTP Contracts
- No request/response: Only message payload
- Metadata: Headers, content-type, message keys
- Ordering: Don't test message ordering in contracts (infrastructure concern)
- Delivery: Don't test delivery guarantees (wrong layer)
Workflow: Same as HTTP (publish pact → verify → can-i-deploy)
CI/CD Integration Quick Reference
GitHub Actions
# Consumer publishes contracts
- name: Run Pact tests
run: npm test
- name: Publish pacts
run: |
npm run pact:publish
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
# Provider verifies and checks deployment
- name: Verify contracts
run: npm run pact:verify
- name: Can I deploy?
run: |
pact-broker can-i-deploy \
--pacticipant UserService \
--version ${{ github.sha }} \
--to-environment production
GitLab CI
pact_test:
script:
- npm test
- npm run pact:publish
pact_verify:
script:
- npm run pact:verify
- pact-broker can-i-deploy --pacticipant UserService --version $CI_COMMIT_SHA --to-environment production
Your First Contract Test
Goal: Prevent breaking changes between two services in one week
Day 1-2: Consumer Side
// Install Pact
npm install --save-dev @pact-foundation/pact
// Consumer contract test (order-service)
const { PactV3, MatchersV3 } = require('@pact-foundation/pact')
const { getUserById } = require('./userClient')
describe('User API', () => {
const provider = new PactV3({
consumer: 'OrderService',
provider: 'UserService'
})
it('gets user by id', () => {
provider
.given('a user exists')
.uponReceiving('a request for user')
.withRequest({
method: 'GET',
path: '/users/123'
})
.willRespondWith({
status: 200,
body: {
id: MatchersV3.integer(123),
name: MatchersV3.string('John')
}
})
return provider.executeTest(async (mockServer) => {
const user = await getUserById(mockServer.url, 123)
expect(user.name).toBe('John')
})
})
})
Day 3-4: Set Up Pact Broker
# Docker Compose
docker-compose up -d
# Or use hosted Pactflow (SaaS)
# https://pactflow.io
Day 5-6: Provider Side
// Provider verification (user-service)
const { Verifier } = require('@pact-foundation/pact')
const app = require('./app')
describe('Pact Verification', () => {
it('validates contracts from broker', () => {
return new Verifier({
provider: 'UserService',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_URL,
publishVerificationResult: true,
providerVersion: '1.0.0',
stateHandlers: {
'a user exists': async () => {
await db.users.create({ id: 123, name: 'John' })
}
}
}).verifyProvider()
})
})
Day 7: Add to CI
# Add can-i-deploy before deployment
- pact-broker can-i-deploy --pacticipant UserService --version $VERSION --to production
Common Mistakes
❌ Testing Business Logic in Contracts
Fix: Contract tests verify integration only. Test business logic separately.
❌ Not Using Matchers
Fix: Use Matchers.string(), Matchers.integer() for flexible matching
❌ Skipping Can-I-Deploy
Fix: Always run can-i-deploy before deployment. Automate in CI.
❌ Hardcoding Test Data
Fix: Generate data in provider states, use matchers in contracts
Quick Reference
Tool Selection:
- Polyglot/multiple teams: Pact
- Java Spring only: Spring Cloud Contract
- Public API: OpenAPI validation
Contract Type:
- Internal services: Consumer-driven (Pact)
- Public API: Provider-driven (OpenAPI)
- Both: Bi-directional
Pact Broker Workflow:
- Consumer publishes pact
- Provider verifies
- Can-i-deploy checks compatibility
- Deploy if compatible
Breaking Changes:
- Add optional field: Safe
- Add required field: Expand/contract pattern
- Remove/rename field: Deprecate → migrate → remove
Provider States:
- Set up test data
- Clean up after each test
- Use transactions for DB
- Don't hardcode IDs
CI/CD:
- Consumer: Test → publish pacts
- Provider: Verify → can-i-deploy → deploy
Bottom Line
Contract testing prevents API breaking changes by testing integration points independently. Use Pact for internal microservices, publish contracts to broker, run can-i-deploy before deployment.
Test the contract (request/response), not the implementation. Use consumer-driven contracts for known consumers, schema-first for public APIs.