API Monitoring

API Contract Testing: Preventing Breaking Changes Before They Reach Production

Learn how API contract testing works, how to implement consumer-driven contracts with Pact, and how to integrate contract testing into your CI/CD pipeline.

AzMonitor TeamFebruary 26, 20258 min read · 1,447 wordsUpdated January 20, 2026
API contract testingPactconsumer-driven contractsAPI versioning

API contract testing solves a specific problem that unit tests and integration tests don't address: ensuring that when a provider team changes an API, those changes don't break the consumers who depend on it. In microservices architectures, this problem is pervasive. The checkout service depends on the payment service API. The payment service team changes a response field name. Checkout breaks in production. Contract testing catches this before deployment.

What API Contract Testing Is

A contract test verifies the agreement between an API provider and its consumers. The "contract" defines:

  • Which endpoints consumers call
  • What request format they send
  • What response structure they expect

The key insight: the consumer defines the contract, not the provider. The provider's test then verifies it can fulfill the consumer's expectations.

This is "consumer-driven contract testing" — the opposite of the naive approach where providers document what they offer and consumers hope they're reading the same documentation.

Consumer-Driven Contracts vs Provider-Driven

Provider-Driven (Traditional, Problematic)

Provider team: "Our API returns this JSON schema"
                ↓
Consumer team: Builds against that schema
                ↓
Provider team: Refactors, renames a field
                ↓
Consumer team: Discovers breakage in production

Consumer-Driven (Contract Testing)

Consumer team: "We expect this structure from your API"
                ↓
Contract is stored and shared
                ↓
Provider team: Runs contract tests before deploy
                ↓
If contract breaks → Pipeline fails, deployment blocked

Implementing Contract Testing with Pact

Pact is the most widely used consumer-driven contract testing framework. It works in both directions:

  1. Consumers define contracts (as part of their tests)
  2. Providers verify they satisfy all consumer contracts

Consumer Side: Define the Contract

// checkout-service/tests/payment-service.pact.spec.js
const { Pact } = require('@pact-foundation/pact');
const path = require('path');
const axios = require('axios');

describe('Payment Service Contract', () => {
  const provider = new Pact({
    consumer: 'checkout-service',
    provider: 'payment-service',
    port: 4000,
    log: path.resolve(process.cwd(), 'logs', 'pact.log'),
    dir: path.resolve(process.cwd(), 'pacts'),
    logLevel: 'warn',
  });
  
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());
  afterEach(() => provider.verify());
  
  describe('POST /charges', () => {
    it('creates a charge successfully', async () => {
      // Define what the consumer expects
      await provider.addInteraction({
        state: 'a payment can be processed',
        uponReceiving: 'a charge request',
        withRequest: {
          method: 'POST',
          path: '/charges',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer token123'
          },
          body: {
            amount: 1000,
            currency: 'usd',
            source: 'tok_visa'
          }
        },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: {
            id: Pact.Matchers.like('ch_abc123'),         // Matches any string
            amount: Pact.Matchers.like(1000),             // Matches any number
            currency: 'usd',                              // Exact match
            status: 'succeeded',                          // Exact match
            created_at: Pact.Matchers.term({              // Matches date format
              generate: '2025-01-15T14:30:00Z',
              matcher: '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z'
            })
          }
        }
      });
      
      // Run the actual consumer code against the mock
      const checkoutClient = new PaymentClient('http://localhost:4000');
      const result = await checkoutClient.createCharge({
        amount: 1000,
        currency: 'usd',
        source: 'tok_visa'
      });
      
      // Verify the consumer code handled the response correctly
      expect(result.id).toBeDefined();
      expect(result.status).toBe('succeeded');
    });
    
    it('handles payment declined', async () => {
      await provider.addInteraction({
        state: 'a payment will be declined',
        uponReceiving: 'a charge request that will be declined',
        withRequest: {
          method: 'POST',
          path: '/charges',
          headers: { 'Content-Type': 'application/json' },
          body: {
            amount: 1000,
            currency: 'usd',
            source: 'tok_chargeDeclined'  // Decline test token
          }
        },
        willRespondWith: {
          status: 402,
          body: {
            error: {
              type: 'card_error',
              code: 'card_declined',
              message: Pact.Matchers.like('Your card was declined.')
            }
          }
        }
      });
      
      // Verify consumer handles error correctly
      const checkoutClient = new PaymentClient('http://localhost:4000');
      await expect(
        checkoutClient.createCharge({ amount: 1000, currency: 'usd', source: 'tok_chargeDeclined' })
      ).rejects.toThrow('card_declined');
    });
  });
});

Running these tests produces a Pact file (contract):

{
  "consumer": {"name": "checkout-service"},
  "provider": {"name": "payment-service"},
  "interactions": [
    {
      "description": "a charge request",
      "providerState": "a payment can be processed",
      "request": {
        "method": "POST",
        "path": "/charges",
        "body": {"amount": 1000, "currency": "usd", "source": "tok_visa"}
      },
      "response": {
        "status": 200,
        "body": {
          "id": "ch_abc123",
          "amount": 1000,
          "currency": "usd",
          "status": "succeeded"
        }
      }
    }
  ]
}

Provider Side: Verify the Contract

// payment-service/tests/pact-verification.spec.js
const { Verifier } = require('@pact-foundation/pact');
const app = require('../src/app');
const path = require('path');

describe('Payment Service - Pact Verification', () => {
  it('validates all consumer contracts', async () => {
    const server = app.listen(3000);
    
    await new Verifier({
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: 'https://your-pact-broker.pactflow.io',
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      provider: 'payment-service',
      
      // Provider state handlers
      stateHandlers: {
        'a payment can be processed': async () => {
          // Set up test data/state for this scenario
          await db.seedTestData({ payment_mode: 'normal' });
        },
        'a payment will be declined': async () => {
          await db.seedTestData({ payment_mode: 'decline' });
        }
      },
      
      publishVerificationResult: true,
      providerVersion: process.env.GIT_COMMIT_SHA,
      
    }).verifyProvider();
    
    server.close();
  });
});

Pact Broker: The Contract Repository

The Pact Broker stores contracts and tracks which provider versions satisfy which consumer contracts:

# Publish consumer contract to Pact Broker
pact-broker publish \
  ./pacts \
  --broker-base-url https://your-broker.pactflow.io \
  --consumer-app-version $(git rev-parse HEAD) \
  --broker-token ${PACT_BROKER_TOKEN}

# Check if provider can be deployed safely
pact-broker can-i-deploy \
  --pacticipant payment-service \
  --version $(git rev-parse HEAD) \
  --to-environment production \
  --broker-base-url https://your-broker.pactflow.io

The can-i-deploy command is the key integration point with CI/CD:

# GitHub Actions workflow
- name: Verify Pact contracts
  run: npm run test:pact

- name: Publish results to Pact Broker
  run: |
    npx pact-broker publish ./pacts \
      --consumer-app-version ${{ github.sha }}

- name: Check deployment safety
  run: |
    npx pact-broker can-i-deploy \
      --pacticipant payment-service \
      --version ${{ github.sha }} \
      --to-environment production
  # This fails (blocks deployment) if any consumer contracts are violated

OpenAPI-Based Contract Testing

If your APIs use OpenAPI/Swagger specifications, you can use spec-based contract testing:

# openapi-validation-config.yml
checks:
  # Verify API responses match OpenAPI spec
  - name: "Payment API - OpenAPI Compliance"
    spec: "./openapi/payment-service.yaml"
    endpoints:
      - path: "/charges"
        method: POST
        validate_request: true
        validate_response: true
        
  - name: "User API - OpenAPI Compliance"
    spec: "./openapi/user-service.yaml"
    endpoints:
      - path: "/users/{id}"
        method: GET
        validate_response: true

Tools like schemathesis, Dredd, and Spectral validate API behavior against OpenAPI specs:

# Schemathesis: Generate and run tests from OpenAPI spec
pip install schemathesis

# Run tests against your API based on OpenAPI spec
schemathesis run \
  https://api.example.com/openapi.json \
  --url https://api.example.com \
  --auth "Bearer ${API_TOKEN}" \
  --checks all

# Output:
# 100 operations / 2000 tests / 0 failures
# No schema validation errors found

Contract Testing in Practice

What to Contract Test

| API Type | Contract Testing Priority | Reason | |---|---|---| | Internal service-to-service | High | Breaking changes are common | | Public API | Medium | Versioning handles this differently | | Third-party dependency | Low (verify their behavior instead) | You don't control their tests | | Database queries | Not applicable | Use integration tests |

What Not to Contract Test

  • Response times (use performance tests)
  • Security behavior (use security tests)
  • Load capacity (use load tests)
  • State management beyond request/response (use integration tests)

Integrating Contract Tests with Monitoring

Contract tests run in CI/CD but don't catch runtime drift. Combine them with runtime monitoring:

# API Contract Monitoring Strategy

# Development/CI: Pact contract tests (catch breaking changes before deploy)
ci_pipeline:
  - consumer_pact_tests
  - can_i_deploy_gate  # Blocks if contracts violated
  - deploy_if_safe

# Production: Runtime validation (catch issues that slip through)
production_monitoring:
  - url: "https://api.example.com/charges"
    assertions:
      # Validate key contract fields still exist in production
      - type: json_path
        path: "$.id"
        operator: exists
      - type: json_path
        path: "$.status"
        operator: in
        values: ["succeeded", "pending", "failed"]

If a contract field disappears in production (due to a deployment that bypassed contracts), your monitoring catches it.

Schema Drift Detection

Track API schema changes over time to detect gradual drift:

# Schema snapshot comparison
def detect_schema_drift(api_url, auth_header, stored_schema_path):
    """
    Compare current API schema against stored baseline.
    Alert when unexpected changes occur.
    """
    import json
    import jsondiff
    
    # Get current schema
    response = requests.get(
        f"{api_url}/openapi.json",
        headers={"Authorization": auth_header}
    )
    current_schema = response.json()
    
    # Load stored baseline
    with open(stored_schema_path) as f:
        baseline_schema = json.load(f)
    
    # Compare
    diff = jsondiff.diff(baseline_schema, current_schema)
    
    if diff:
        # Classify changes
        breaking_changes = []
        non_breaking_changes = []
        
        for path, change in flatten_diff(diff).items():
            if is_breaking_change(path, change):
                breaking_changes.append({"path": path, "change": change})
            else:
                non_breaking_changes.append({"path": path, "change": change})
        
        return {
            "drift_detected": True,
            "breaking_changes": breaking_changes,
            "non_breaking_changes": non_breaking_changes,
            "recommendation": "Review breaking changes before deploying"
        }
    
    return {"drift_detected": False}

Conclusion

API contract testing is the prevention layer in your reliability stack. It catches breaking API changes before they reach production, where they become incidents. Consumer-driven contracts with Pact are the most rigorous approach for microservices; OpenAPI-based validation works well for API-first teams with formal specifications. Neither replaces runtime monitoring — contract tests verify structure, not availability. Use AzMonitor's API monitoring to continuously verify your contracts are being fulfilled in production, with response body assertions that validate key contract fields are present and correctly typed. Together, contract testing and runtime monitoring provide both "can't deploy" and "alerts if it breaks" coverage for your API agreements.

Tags:API contract testingPactconsumer-driven contractsAPI versioning
Back to blog
A
AzMonitor Team
The AzMonitor team writes guides based on experience monitoring millions of endpoints daily across 10,000+ customer environments. Our expertise covers uptime monitoring, SRE practices, and reliability engineering.
Try AzMonitor free

3 monitors free forever · No credit card needed · Set up in 2 minutes

Start monitoring free →