Problem: Microservices Break in Production Despite Passing Tests
Your consumer service passes all unit tests, but crashes in production because the provider API changed response format. Traditional integration tests are too slow and brittle to catch this.
You'll learn:
- Why contract testing prevents integration failures
- How to implement Pact for API contract verification
- Using AI to generate and maintain contracts automatically
Time: 20 min | Level: Intermediate
Why This Happens
Traditional testing approaches have a fatal gap: unit tests mock external APIs, and full integration tests require running entire microservice clusters. When a provider changes their API, consumers don't know until deployment.
Common symptoms:
- "Cannot read property of undefined" in production
- 500 errors after provider deploys
- Breaking changes pass CI/CD pipeline
- Slow, flaky end-to-end tests
The core issue: Testing happens in isolation, not at the contract boundary where services actually interact.
What Contract Testing Solves
Contract testing verifies the agreement between service consumer and provider independently. Think of it as API type checking across service boundaries.
Traditional flow:
Consumer tests → Mock API → ✅ Pass
Provider tests → Real code → ✅ Pass
Production → 💥 Consumer gets unexpected format
Contract testing flow:
Consumer → Generates contract → Pact Broker
Provider → Verifies contract → ✅ Both compatible
Production → ✅ No surprises
Solution
Step 1: Install Pact for Your Stack
Consumer side (TypeScript/Node.js):
npm install --save-dev @pact-foundation/pact
Provider side (any language - examples for Node/Go/Python):
# Node.js provider
npm install --save-dev @pact-foundation/pact
# Go provider
go get github.com/pact-foundation/pact-go/v2
# Python provider
pip install pact-python --break-system-packages
Expected: Dependencies install without errors. Pact requires running a local broker or using Pactflow.
Step 2: Write Consumer Contract Test
Create a test that defines what your service expects from the API.
// tests/user-api.contract.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, integer } = MatchersV3;
const provider = new PactV3({
consumer: 'UserDashboard',
provider: 'UserService',
dir: './pacts', // Where contracts are saved
});
describe('User API Contract', () => {
it('gets user profile with orders', async () => {
// Define expected interaction
await provider
.given('user 123 exists with 2 orders')
.uponReceiving('a request for user profile')
.withRequest({
method: 'GET',
path: '/api/users/123',
headers: { Authorization: like('Bearer token') },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(123),
name: like('John Doe'),
email: like('john@example.com'),
// Must be array of at least 1 order
orders: eachLike({
orderId: integer(1),
total: like(99.99),
status: like('completed'),
}, { min: 1 }),
},
})
.executeTest(async (mockServer) => {
// Your actual API client code
const client = new UserApiClient(mockServer.url);
const user = await client.getUser('123');
// Verify your code handles response correctly
expect(user.orders.length).toBeGreaterThan(0);
expect(user.orders[0].orderId).toBeDefined();
});
});
});
Why this works: like() matches types, not exact values. eachLike() validates array structure. When this test runs, it generates a contract file in ./pacts/.
Run it:
npm test -- user-api.contract.test.ts
You should see: Test passes and creates pacts/UserDashboard-UserService.json
Step 3: Publish Contract to Broker
Contracts need central storage so providers can verify them.
Option A: Run local Pact Broker (Docker):
docker run -d --name pact-broker \
-p 9292:9292 \
-e PACT_BROKER_DATABASE_URL=sqlite:///pact_broker.sqlite \
pactfoundation/pact-broker:latest
Option B: Use Pactflow (managed service): Sign up at pactflow.io for free tier.
Publish contract:
// package.json scripts
{
"scripts": {
"pact:publish": "pact-broker publish ./pacts --consumer-app-version=$GIT_COMMIT --broker-base-url=http://localhost:9292"
}
}
# In CI/CD or locally
GIT_COMMIT=$(git rev-parse HEAD) npm run pact:publish
Expected: See contract uploaded at http://localhost:9292
Step 4: Verify Contract on Provider Side
The provider service validates it can fulfill the consumer's contract.
// provider/tests/verify-contract.test.ts
import { Verifier } from '@pact-foundation/pact';
import { startServer } from '../src/server'; // Your actual API server
describe('Pact Verification', () => {
let server;
beforeAll(async () => {
server = await startServer(8080);
});
afterAll(() => server.close());
it('validates UserDashboard contract', () => {
return new Verifier({
providerBaseUrl: 'http://localhost:8080',
provider: 'UserService',
// Fetch contracts from broker
pactBrokerUrl: 'http://localhost:9292',
consumerVersionSelectors: [
{ mainBranch: true }, // Test against main branch
{ deployedOrReleased: true }, // And production versions
],
// Setup test data for "given" states
stateHandlers: {
'user 123 exists with 2 orders': async () => {
// Seed test database
await db.seed({
users: [{ id: 123, name: 'John Doe', email: 'john@example.com' }],
orders: [
{ userId: 123, orderId: 1, total: 99.99, status: 'completed' },
{ userId: 123, orderId: 2, total: 49.99, status: 'shipped' },
],
});
},
},
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
}).verifyProvider();
});
});
Run verification:
GIT_COMMIT=$(git rev-parse HEAD) npm test -- verify-contract.test.ts
If it fails:
- Error: "Expected 'orderId' but got 'order_id'": Your API response doesn't match contract. Fix the API or update consumer expectations.
- Error: "State handler not found": Add missing state to
stateHandlers. - Error: "Connection refused": Provider server not running on specified port.
You should see: "Pact verification successful" and results published to broker.
Step 5: AI-Powered Contract Generation (Optional)
Use AI to generate contracts from OpenAPI specs or existing API traffic.
From OpenAPI spec:
// scripts/generate-contracts.ts
import { generatePactFromOpenAPI } from '@pact-foundation/pact-openapi';
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
async function generateSmartContract(openapiPath: string) {
const openapi = await fs.readFile(openapiPath, 'utf-8');
// Use Claude to identify critical paths
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
messages: [{
role: 'user',
content: `Analyze this OpenAPI spec and identify the 5 most critical API endpoints
that would cause production issues if they broke. Return as JSON array with path,
method, and reason.
${openapi}`
}]
});
const criticalPaths = JSON.parse(response.content[0].text);
// Generate Pact contracts for critical paths only
const contracts = await generatePactFromOpenAPI({
openapi: openapiPath,
provider: 'UserService',
consumer: 'UserDashboard',
paths: criticalPaths.map(p => ({ path: p.path, method: p.method })),
});
await fs.writeFile('./pacts/generated.json', JSON.stringify(contracts, null, 2));
}
generateSmartContract('./openapi.yaml');
Why this helps: AI identifies which endpoints matter most, preventing contract bloat. Manual contract writing is tedious for large APIs.
Run it:
npx ts-node scripts/generate-contracts.ts
Step 6: Integrate with CI/CD
Prevent deployments that break contracts.
GitHub Actions example:
# .github/workflows/contract-tests.yml
name: Contract Tests
on: [push, pull_request]
jobs:
consumer-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Run consumer contract tests
run: |
npm ci
npm run test:contract
- name: Publish contracts
if: github.ref == 'refs/heads/main'
env:
GIT_COMMIT: ${{ github.sha }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: npm run pact:publish
provider-verification:
runs-on: ubuntu-latest
needs: consumer-contract
steps:
- uses: actions/checkout@v4
- name: Verify provider contracts
env:
GIT_COMMIT: ${{ github.sha }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: npm run pact:verify
- name: Can I Deploy?
run: |
pact-broker can-i-deploy \
--pacticipant=UserService \
--version=${{ github.sha }} \
--to-environment=production
Can-I-Deploy check: Blocks deployment if provider hasn't verified consumer contracts. This prevents breaking changes reaching production.
Verification
Check contract compatibility:
# See all contract verification results
pact-broker list-latest-pact-versions \
--broker-base-url=http://localhost:9292
# Check if specific version can deploy
pact-broker can-i-deploy \
--pacticipant=UserService \
--version=$(git rev-parse HEAD) \
--to-environment=production
You should see: Either "Computer says yes!" (safe to deploy) or specific incompatibilities listed.
Monitor in production:
// Add Pact verification to provider health checks
app.get('/health/contracts', async (req, res) => {
const status = await checkPactVerificationStatus();
res.json({
status: status.allPassing ? 'healthy' : 'degraded',
contracts: status.results,
});
});
What You Learned
- Contract testing catches integration issues before production
- Pact verifies consumer expectations against provider behavior
- AI can generate contracts from specs and prioritize critical paths
- Can-I-Deploy prevents breaking deployments
When NOT to use this:
- Single monolithic application (no service boundaries)
- Internal libraries within same repo (use TypeScript types)
- Non-HTTP communication without Pact support (gRPC works, but requires plugins)
Limitations:
- Requires discipline to maintain "given" states
- Contract broker adds infrastructure dependency
- Learning curve for matchers and contract design
Advanced: AI Contract Validation
Use AI to detect semantic breaking changes that syntax alone misses.
// scripts/validate-breaking-changes.ts
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
async function validateContractChange(oldContract: string, newContract: string) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 2000,
messages: [{
role: 'user',
content: `Compare these two API contracts and identify breaking changes that would
cause runtime errors in consumers. Focus on:
- Removed required fields
- Changed data types
- New required parameters
- Changed error codes
Old contract:
${oldContract}
New contract:
${newContract}
Return JSON: { "breaking": true/false, "changes": [{ "field": "...", "impact": "...", "severity": "high/medium/low" }] }`
}]
});
const analysis = JSON.parse(response.content[0].text);
if (analysis.breaking) {
console.error('⚠️ Breaking changes detected:');
analysis.changes.forEach(change => {
console.error(` - ${change.field}: ${change.impact} (${change.severity})`);
});
process.exit(1);
}
}
Use in CI:
- name: Validate contract changes
run: npx ts-node scripts/validate-breaking-changes.ts
Common Patterns
Pattern 1: Backward-Compatible Expansion
// ✅ Safe: Adding optional field
{
id: integer(123),
name: like('John'),
newField: like('value'), // Provider adds, consumer ignores
}
// ❌ Breaking: Removing field
{
id: integer(123),
// name removed - breaks consumers expecting it
}
Pattern 2: Versioned Contracts
// Support multiple API versions simultaneously
await provider
.given('user 123 exists')
.uponReceiving('a request for user profile v2')
.withRequest({
method: 'GET',
path: '/api/v2/users/123',
headers: { 'Accept': 'application/vnd.api.v2+json' },
})
.willRespondWith({
status: 200,
body: { /* v2 response format */ },
});
Pattern 3: Consumer-Driven Changes
// Consumer proposes new field
await provider
.given('user 123 exists with subscription')
.uponReceiving('a request including subscription status')
.withRequest({
method: 'GET',
path: '/api/users/123',
query: { include: 'subscription' },
})
.willRespondWith({
status: 200,
body: {
id: integer(123),
subscription: { // Provider implements if contract passes
tier: like('premium'),
expiresAt: like('2026-12-31'),
},
},
});
Troubleshooting
Error: "Pact verification failed - actual response differs from expected"
Check exact field names and types:
# See detailed diff
pact-broker verify \
--provider-base-url=http://localhost:8080 \
--verbose
Error: "State handler timed out"
Increase timeout and check database seeding:
stateHandlers: {
'user 123 exists': async () => {
await db.seed({ /* data */ });
// Verify it worked
const user = await db.users.findById(123);
if (!user) throw new Error('Seed failed');
},
},
requestTimeout: 30000, // 30 seconds
Error: "Cannot connect to Pact Broker"
Verify broker is running:
curl http://localhost:9292/diagnostic/status/heartbeat
# Should return: { "ok": true }
Tested on Pact v12, Node.js 22.x, TypeScript 5.5, Docker 27.x Works with React, Vue, Angular on consumer side Works with Express, Fastify, Go, Python, Rust on provider side