Integration tests catch real bugs but are slow and flaky — they require both services to be running simultaneously. Contract testing inverts the model: each service verifies its contracts independently, in isolation, without needing the other service alive. When both pass their contract tests, integration is guaranteed to work.
Claude Code generates Pact consumer tests and provider verifications that catch breaking API changes before deployment.
Consumer-Driven Contract Testing with Pact
Set up Pact contract testing between our order service (consumer)
and the product service (provider).
The order service calls GET /products/:id to check stock.
Consumer Test (Order Service)
// order-service/tests/product-service.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { resolve } from 'path';
import { ProductServiceClient } from '../src/clients/product-service';
const { like, integer, string, eachLike } = MatchersV3;
const provider = new PactV3({
consumer: 'OrderService',
provider: 'ProductService',
dir: resolve(process.cwd(), 'pacts'), // Pact files written here
logLevel: 'warn',
});
describe('Product Service contract', () => {
describe('GET /products/:id', () => {
it('returns product details when product exists', async () => {
await provider
.given('product 123 exists with sufficient stock')
.uponReceiving('a request for product 123')
.withRequest({
method: 'GET',
path: '/products/123',
headers: {
Accept: 'application/json',
Authorization: like('Bearer some-token'),
},
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: string('123'),
name: string('Widget Pro'),
priceCents: integer(4999),
stock: integer(50),
available: true,
},
})
.executeTest(async (mockServer) => {
// The client hits the mock server — doesn't need real ProductService
const client = new ProductServiceClient(mockServer.url);
const product = await client.getProduct('123');
expect(product.id).toBe('123');
expect(product.stock).toBeGreaterThan(0);
expect(product.available).toBe(true);
});
});
it('returns 404 when product does not exist', async () => {
await provider
.given('product 999 does not exist')
.uponReceiving('a request for non-existent product 999')
.withRequest({ method: 'GET', path: '/products/999' })
.willRespondWith({
status: 404,
body: { error: string('Product not found') },
})
.executeTest(async (mockServer) => {
const client = new ProductServiceClient(mockServer.url);
await expect(client.getProduct('999')).rejects.toThrow('Product not found');
});
});
});
describe('POST /products/:id/reserve', () => {
it('reserves stock successfully', async () => {
await provider
.given('product 123 has 50 units in stock')
.uponReceiving('a stock reservation request')
.withRequest({
method: 'POST',
path: '/products/123/reserve',
headers: { 'Content-Type': 'application/json' },
body: { quantity: integer(2), orderId: string('order-456') },
})
.willRespondWith({
status: 200,
body: {
reservationId: string('res-789'),
expiresAt: string('2024-01-01T12:00:00Z'),
},
})
.executeTest(async (mockServer) => {
const client = new ProductServiceClient(mockServer.url);
const result = await client.reserveStock('123', 2, 'order-456');
expect(result.reservationId).toBeDefined();
});
});
});
});
The consumer test writes a pact file to pacts/OrderService-ProductService.json. Now the ProductService verifies against this file.
Provider Verification (Product Service)
// product-service/tests/pact-provider.test.ts
import { Verifier } from '@pact-foundation/pact';
import { resolve } from 'path';
import { app } from '../src/app';
import { db } from '../src/db';
import { createServer } from 'http';
describe('Pact verification — ProductService as provider', () => {
let server: ReturnType<typeof createServer>;
let port: number;
beforeAll(async () => {
server = createServer(app);
await new Promise<void>(resolve => server.listen(0, resolve));
port = (server.address() as any).port;
});
afterAll(() => server.close());
it('verifies all consumer contracts', async () => {
const verifier = new Verifier({
provider: 'ProductService',
providerBaseUrl: `http://localhost:${port}`,
// In CI: fetch from Pact Broker
// Locally: read from filesystem
pactUrls: [resolve(process.cwd(), '../order-service/pacts/OrderService-ProductService.json')],
// OR:
// pactBrokerUrl: process.env.PACT_BROKER_URL,
// pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// State handlers — set up test data for each provider state
stateHandlers: {
'product 123 exists with sufficient stock': async () => {
await db('products').insert({
id: '123',
name: 'Widget Pro',
price_cents: 4999,
stock: 50,
available: true,
}).onConflict('id').merge();
},
'product 999 does not exist': async () => {
await db('products').where('id', '999').delete();
},
'product 123 has 50 units in stock': async () => {
await db('products').where('id', '123').update({ stock: 50 });
},
},
// Add auth token to requests from Pact verifier
requestFilter: (req, _res, next) => {
req.headers.authorization = 'Bearer test-token';
next();
},
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT_SHA ?? 'local',
});
await verifier.verifyProvider();
});
});
Pact Broker Integration
Set up a Pact Broker for sharing contracts between teams.
Integrate with CI so deployments only proceed when contracts pass.
# docker-compose.yml — run locally or deploy to your infra
version: '3'
services:
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: "postgres://pactuser:password@db/pact_broker"
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: ${PACT_BROKER_PASSWORD}
depends_on:
- db
db:
image: postgres:15
environment:
POSTGRES_DB: pact_broker
POSTGRES_USER: pactuser
POSTGRES_PASSWORD: password
# .github/workflows/contract-tests.yml
name: Contract Tests
on: [push, pull_request]
jobs:
consumer-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm test -- --testPathPattern=pact
# Publish pact files to broker
- name: Publish pacts
run: |
npx pact-broker publish ./pacts \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKEN \
--consumer-app-version ${{ github.sha }} \
--branch ${{ github.ref_name }}
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
provider-verification:
runs-on: ubuntu-latest
needs: consumer-tests
steps:
- uses: actions/checkout@v4
- run: npm ci
# Verify all consumer contracts from broker
- name: Verify provider contracts
run: npm test -- --testPathPattern=pact-provider
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_COMMIT_SHA: ${{ github.sha }}
CI: 'true'
can-i-deploy:
runs-on: ubuntu-latest
needs: provider-verification
steps:
# Check if this version can safely be deployed
- name: Can I deploy?
run: |
npx pact-broker can-i-deploy \
--pacticipant ProductService \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKEN
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
can-i-deploy prevents deployment if any consumer contracts would break. The ProductService can only deploy to production when all its consumer contracts pass.
CLAUDE.md for Contract Testing
## Contract Testing
- Pact broker: https://pact.internal.company.com
- Consumer pacts in: ./pacts/ (gitignored — generated at test time)
- Provider verification: npm test -- --testPathPattern=pact-provider
- State handlers: tests/pact-states.ts — must cover all provider states in consumer tests
- Never remove a field from a response without checking the Pact Broker for consumers
- New response fields are safe (consumers ignore unknown fields)
- Changed field types or removed fields require consumer team coordination
Testing API Compatibility Without Pact
We don't have a Pact broker. Use JSON Schema to version our API
and detect breaking changes in CI.
// scripts/check-api-compatibility.ts
import { OpenAPIV3 } from 'openapi-types';
import * as diff from 'openapi-diff';
async function checkBreakingChanges() {
const result = await diff.diffSpecs({
sourceSpec: { content: JSON.stringify(require('./api-v1.json')), format: 'openapi3' },
destinationSpec: { content: JSON.stringify(require('./api-current.json')), format: 'openapi3' },
});
const breakingChanges = result.breakingDifferences;
if (breakingChanges.length > 0) {
console.error('BREAKING API CHANGES DETECTED:');
for (const change of breakingChanges) {
console.error(` - ${change.action}: ${change.code} at ${change.sourceSpecEntityDetails?.[0]?.location}`);
}
process.exit(1);
}
console.log(`✓ No breaking changes. ${result.nonBreakingDifferences.length} non-breaking changes.`);
}
checkBreakingChanges();
# In CI:
- name: Check for breaking API changes
run: |
# Export current OpenAPI spec
npm run export-schema > api-current.json
# Compare against last deployed version
git show origin/main:api-schema.json > api-v1.json
npx ts-node scripts/check-api-compatibility.ts
For microservices architecture patterns that make contract testing necessary, see the microservices guide. For testing APIs end-to-end in staging environments, see the API testing guide. The Claude Skills 360 bundle includes contract testing skill sets for Pact setup, broker configuration, and CI integration. Start with the free tier to try contract test scaffolding.