Published on

API Contract Testing With Pact — Catching Breaking Changes Before They Hit Production

Authors

Introduction

Integration tests are slow. End-to-end tests are flaky. What if you could verify that your API actually delivers what each client expects, without firing up the entire stack? Consumer-driven contract testing with Pact does exactly that: clients define what they expect, providers prove they deliver it, and a broker keeps everyone in sync.

Consumer-Driven Contract Testing Concept

In traditional testing, the provider (API) defines the contract. In Pact, the consumer (client) defines it. Your client code explicitly states: "I need this endpoint to return this shape." The provider must verify: "Yes, we actually return that."

This reversal prevents the silent contract breakage that happens when APIs change but no client was actually using the old format.

// Consumer perspective: What does the Orders API need to provide?
// src/consumer/orders.test.ts

import { PactV3 } from '@pact-foundation/pact'
import fetch from 'node-fetch'

const pact = new PactV3({
  consumer: 'WebApp',
  provider: 'OrdersAPI',
  dir: './pacts'
})

describe('Orders API Consumer', () => {
  it('should fetch order details', async () => {
    // Define the interaction we expect
    await pact
      .addInteraction()
      .given('an order with id 123 exists')
      .uponReceiving('a request for order details')
      .withRequest('GET', '/orders/123')
      .willRespondWith(200, {
        body: {
          id: '123',
          status: 'confirmed',
          total: 99.99,
          items: [
            {
              productId: 'WIDGET-001',
              quantity: 2,
              price: 49.99
            }
          ],
          createdAt: '2026-03-15T10:00:00Z'
        }
      })
      .executeTest(async interaction => {
        // Now test our code against this expectation
        const response = await fetch(`${interaction.baseUrl}/orders/123`)
        const order = await response.json()

        expect(response.status).toBe(200)
        expect(order.id).toBe('123')
        expect(order.status).toBe('confirmed')
        expect(order.items).toHaveLength(1)
      })
  })

  it('should list recent orders', async () => {
    await pact
      .addInteraction()
      .given('user has orders')
      .uponReceiving('a request for order list')
      .withRequest('GET', '/orders', {
        headers: { Authorization: 'Bearer token123' }
      })
      .willRespondWith(200, {
        body: {
          data: [
            {
              id: '123',
              status: 'confirmed',
              total: 99.99
            }
          ],
          pagination: {
            hasMore: false
          }
        }
      })
      .executeTest(async interaction => {
        const response = await fetch(`${interaction.baseUrl}/orders`, {
          headers: { Authorization: 'Bearer token123' }
        })
        const result = await response.json()

        expect(result.data).toBeDefined()
        expect(Array.isArray(result.data)).toBe(true)
        expect(result.pagination).toBeDefined()
      })
  })

  it('should return 404 for non-existent order', async () => {
    await pact
      .addInteraction()
      .given('order with id 999 does not exist')
      .uponReceiving('a request for non-existent order')
      .withRequest('GET', '/orders/999')
      .willRespondWith(404, {
        body: {
          error: 'Order not found'
        }
      })
      .executeTest(async interaction => {
        const response = await fetch(`${interaction.baseUrl}/orders/999`)
        expect(response.status).toBe(404)
      })
  })
})

Pact Consumer Test: Define What You Expect

The consumer test is your contract definition. It runs against a mock provider that validates every request matches expectations.

// src/consumer/payment-service.test.ts
import { PactV3 } from '@pact-foundation/pact'

const pact = new PactV3({
  consumer: 'CheckoutUI',
  provider: 'PaymentService',
  dir: './pacts'
})

describe('Payment Service Consumer', () => {
  it('should create payment intent', async () => {
    await pact
      .addInteraction()
      .uponReceiving('a request to create payment intent')
      .withRequest('POST', '/payment-intents', {
        body: {
          amount: 9999,
          currency: 'USD',
          description: 'Order #123'
        }
      })
      .willRespondWith(201, {
        body: {
          id: 'pi_123abc',
          clientSecret: 'pi_123abc_secret_xyz',
          status: 'requires_payment_method',
          amount: 9999,
          currency: 'USD'
        }
      })
      .executeTest(async interaction => {
        const response = await fetch(
          `${interaction.baseUrl}/payment-intents`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              amount: 9999,
              currency: 'USD',
              description: 'Order #123'
            })
          }
        )

        expect(response.status).toBe(201)
        const intent = await response.json()
        expect(intent.id).toBeDefined()
        expect(intent.clientSecret).toBeDefined()
        expect(intent.status).toBe('requires_payment_method')
      })
  })

  it('should handle validation errors', async () => {
    await pact
      .addInteraction()
      .uponReceiving('a request with invalid amount')
      .withRequest('POST', '/payment-intents', {
        body: {
          amount: -100,
          currency: 'USD'
        }
      })
      .willRespondWith(400, {
        body: {
          error: 'Amount must be positive',
          code: 'INVALID_AMOUNT'
        }
      })
      .executeTest(async interaction => {
        const response = await fetch(
          `${interaction.baseUrl}/payment-intents`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              amount: -100,
              currency: 'USD'
            })
          }
        )

        expect(response.status).toBe(400)
      })
  })
})

Pact Provider Verification: Prove You Deliver It

The provider runs Pact verification. It reads all consumer contracts and proves that the actual API implementation satisfies them.

// provider/orders-api.test.ts
import { Verifier } from '@pact-foundation/pact'
import app from './app'

describe('Orders API Provider', () => {
  it('should satisfy all Orders API consumer contracts', async () => {
    const verifier = new Verifier({
      provider: 'OrdersAPI',
      consumerVersionSelectors: [
        { deployed: true },
        { mainBranch: true }
      ],
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: 'https://pact-broker.mycompany.com',
      publishVerificationResult: true,
      providerVersion: process.env.VERSION || 'latest'
    })

    // Set up test database state
    beforeEach(async () => {
      await db.seed()
    })

    return verifier.verifyProvider()
  })
})

// Alternative: Local pact files (before Pact Broker is set up)
describe('Orders API Provider - Local', () => {
  it('should satisfy consumer pacts', async () => {
    const verifier = new Verifier({
      provider: 'OrdersAPI',
      providerBaseUrl: 'http://localhost:3000',
      pactFiles: ['./pacts/webapp-ordersapi.json'],
      stateHandlers: {
        'an order with id 123 exists': async () => {
          await db.orders.create({
            id: '123',
            status: 'confirmed',
            total: 99.99,
            items: [{ productId: 'WIDGET-001', quantity: 2, price: 49.99 }],
            createdAt: new Date('2026-03-15')
          })
        },
        'user has orders': async () => {
          await db.orders.create({
            id: '123',
            status: 'confirmed',
            total: 99.99
          })
        },
        'order with id 999 does not exist': async () => {
          // Ensure it doesn't exist
          await db.orders.delete({ where: { id: '999' } })
        }
      }
    })

    return verifier.verifyProvider()
  })
})

On the provider side, implement the actual endpoints:

// provider/handlers/orders.ts
import express from 'express'
import { db } from '../db'

export const ordersRouter = express.Router()

ordersRouter.get('/orders/:id', async (req, res) => {
  try {
    const order = await db.orders.findUnique({
      where: { id: req.params.id },
      include: { items: true }
    })

    if (!order) {
      return res.status(404).json({ error: 'Order not found' })
    }

    res.json({
      id: order.id,
      status: order.status,
      total: order.total,
      items: order.items.map(item => ({
        productId: item.productId,
        quantity: item.quantity,
        price: item.price
      })),
      createdAt: order.createdAt.toISOString()
    })
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' })
  }
})

ordersRouter.get('/orders', async (req, res) => {
  try {
    const orders = await db.orders.findMany({
      where: { userId: req.user.id },
      take: 50
    })

    res.json({
      data: orders.map(o => ({
        id: o.id,
        status: o.status,
        total: o.total
      })),
      pagination: {
        hasMore: false
      }
    })
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' })
  }
})

Pact Broker: Sharing and Managing Contracts

A Pact Broker is a central repository. Consumers publish their pacts, providers fetch and verify them. It tracks who depends on whom and prevents integration failures before they happen.

// CI/CD: Consumer publishes pact
// .github/workflows/publish-pact.yml
name: Publish Pact

on:
  push:
    branches: [main, develop]

jobs:
  test-and-publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - run: npm ci
      - run: npm test -- --testPathPattern=consumer

      - name: Publish pact
        run: |
          npx pact-broker publish pacts/ \
            --consumer-app-version=${{ github.sha }} \
            --branch=${{ github.ref_name }} \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-username=${{ secrets.PACT_BROKER_USER }} \
            --broker-password=${{ secrets.PACT_BROKER_PASS }}
// CI/CD: Provider verifies pacts
// .github/workflows/verify-pacts.yml
name: Verify Pacts

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      consumerVersion:
        description: 'Consumer version to verify against'
        required: false

jobs:
  verify:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - run: npm ci
      - run: npm run build

      - name: Verify pacts
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant=OrdersAPI \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-username=${{ secrets.PACT_BROKER_USER }} \
            --broker-password=${{ secrets.PACT_BROKER_PASS }}

      - name: Run provider verification
        run: npm test -- --testPathPattern=provider
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_USER: ${{ secrets.PACT_BROKER_USER }}
          PACT_BROKER_PASS: ${{ secrets.PACT_BROKER_PASS }}

CI Integration: Consumer Publishes, Provider Verifies

The contract lifecycle:

  1. Consumer writes test expecting API behavior
  2. Consumer test runs against mock, generates pact file
  3. CI publishes pact to Broker
  4. Provider CI fetches latest pacts from Broker
  5. Provider runs verification against real code
  6. Result published back to Broker
  7. Dashboard shows compatibility matrix
// Use Pact in contract-first workflow
// Generate consumer SDK from pact (optional but powerful)

// src/api-client/generated/orders-client.ts
// This could be auto-generated from the pact contract
export class OrdersAPIClient {
  constructor(private baseUrl: string) {}

  async getOrder(orderId: string): Promise<Order> {
    const response = await fetch(`${this.baseUrl}/orders/${orderId}`)
    if (!response.ok) throw new Error('Order not found')
    return response.json()
  }

  async listOrders(): Promise<{ data: Order[]; pagination: Pagination }> {
    const response = await fetch(`${this.baseUrl}/orders`)
    return response.json()
  }

  async createPaymentIntent(amount: number): Promise<PaymentIntent> {
    const response = await fetch(`${this.baseUrl}/payment-intents`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount, currency: 'USD' })
    })
    if (!response.ok) throw new Error('Failed to create payment intent')
    return response.json()
  }
}

Handling Backwards-Compatible Changes

Not all changes are breaking. Pact helps identify which are:

// SAFE: Adding optional field
consumer expects: { id, status, total }
provider returns: { id, status, total, createdAt }
// OK - consumer ignores unknown fields

// UNSAFE: Removing required field
consumer expects: { id, status, total }
provider returns: { id, status }
// BROKEN - consumer crashes on missing field

// SAFE: Renaming and keeping old field
// Gradually migrate consumers

// Provider adds new field but keeps old one
const response = {
  id: order.id,
  status: order.status,
  // Old field for backwards compatibility
  orderStatus: order.status,
  total: order.total
}

// Provider can remove old field after all consumers upgraded

Schema Evolution Strategies

// Strategy 1: Strangler pattern in API evolution
// Old endpoint still works, new endpoint available in parallel

app.get('/orders/:id', oldOrderHandler) // Deprecated but works
app.get('/v2/orders/:id', newOrderHandler) // New contract

// Strategy 2: Required → Optional field transition
// Phase 1: Keep field, mark as deprecated
{
  orderId: '123',
  total: 99.99,
  deprecated_status: 'confirmed' // Will be removed
}

// Phase 2: Introduce new field
{
  orderId: '123',
  total: 99.99,
  deprecated_status: 'confirmed',
  status: 'confirmed'
}

// Phase 3: Wait for all consumers to migrate
// Phase 4: Remove deprecated field
{
  orderId: '123',
  total: 99.99,
  status: 'confirmed'
}

// Strategy 3: Use content negotiation
app.get('/orders/:id', (req, res) => {
  const apiVersion = req.headers['accept-version'] || '1'

  if (apiVersion === '2') {
    return res.json({ orderId: '123', total: 99.99, status: 'confirmed' })
  }

  res.json({
    id: '123',
    total: 99.99,
    status: 'confirmed'
  })
})

Contract Testing vs Integration Testing

AspectContract TestingIntegration Testing
SpeedVery fastSlow
ScopeAPI boundary onlyFull system
MockingProvider mockedNothing mocked
CoverageCatches breaking changesCatches runtime issues
Best forPreventing silent contract breakageVerifying complete workflows

Use both: contract tests catch incompatibilities early, integration tests verify the whole system works.

// Typical test pyramid
describe('Order Processing', () => {
  // Unit tests: 70% - fastest
  describe('Order entity', () => {
    it('calculates total correctly', () => {
      const order = new Order([{ price: 10, qty: 2 }])
      expect(order.total).toBe(20)
    })
  })

  // Contract tests: 20% - medium speed
  describe('API contracts', () => {
    it('satisfies consumer expectations', async () => {
      // Pact test
    })
  })

  // Integration tests: 10% - slowest
  describe('End-to-end', () => {
    it('can create order from UI to database', async () => {
      // Full stack test
    })
  })
})

Checklist

  • Consumer tests define expected API behavior
  • Pact files generated from consumer tests
  • Provider verification tests all consumer contracts
  • Pact Broker set up for contract sharing
  • CI publishes consumer pacts after tests pass
  • Provider CI verifies against latest pacts
  • Backwards-compatibility rules documented
  • Schema evolution follows strangler pattern
  • Contract changes reviewed before deployment
  • Can-I-Deploy check prevents incompatible releases

Conclusion

Contract testing with Pact gives you the safety of integration tests with the speed of unit tests. You catch breaking changes in CI, not in production. Each team owns their contract and proves compliance independently, without coordinating full-stack deployments. It's the foundation of confident service independence.