Catch Breaking API Changes Before Production in 20 Minutes

Implement contract testing with Pact to prevent microservice integration failures. Learn AI-powered contract generation and validation.

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