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

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Integration tests are slow, end-to-end tests are flaky, and manual coordination between frontend and backend teams breaks down at scale. Consumer-driven contract testing with Pact solves this: clients define what they expect from an API, providers prove they deliver it, and a central broker keeps everyone synchronized.

How Consumer-Driven Contract Testing Works

Traditional testing defines contracts from the provider's perspective. Pact inverts this: the consumer (client) defines what it expects, and the provider proves it delivers that exact shape. This catches the silent contract breakage that happens when APIs change but no client was actually consuming the changed field.

The lifecycle has three stages:

  1. Consumer writes a test that defines expected API interactions
  2. Pact generates a contract file (JSON) from the consumer test
  3. Provider runs verification against the contract — it must satisfy every consumer's expectations
// Consumer test: define what the Orders API must provide
const { PactV3 } = require('@pact-foundation/pact');
 
const pact = new PactV3({
  consumer: 'WebApp',
  provider: 'OrdersAPI',
  dir: './pacts'
});
 
describe('Orders API Consumer', () => {
  it('fetches order details', async () => {
    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) => {
        const response = await fetch(`${interaction.baseUrl}/orders/123`);
        const order = await response.json();
        expect(order.id).toBe('123');
        expect(order.status).toBe('confirmed');
      });
  });
});

Writing Consumer Tests

Consumer tests run against a Pact mock provider. The mock validates that every request matches the interaction definition. If the consumer code sends an unexpected header or parameter, the test fails immediately — catching contract violations on the consumer side before the provider is ever involved.

// Additional consumer test: error handling
describe('Orders API - not found', () => {
  it('returns 404 for missing 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);
      });
  });
});

Provider Verification

The provider reads consumer contracts and verifies that the actual API implementation satisfies them. State handlers set up the database state each interaction requires.

// Provider verification test
const { Verifier } = require('@pact-foundation/pact');
 
describe('Orders API Provider', () => {
  it('satisfies all consumer contracts', async () => {
    const verifier = new Verifier({
      provider: 'OrdersAPI',
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: 'https://pact-broker.mycompany.com',
      publishVerificationResult: true,
      providerVersion: process.env.VERSION || 'local',
      stateHandlers: {
        'an order with id 123 exists': async () => {
          await db.orders.upsert({
            where: { id: '123' },
            update: {},
            create: {
              id: '123',
              status: 'confirmed',
              total: 99.99,
              items: [{ productId: 'WIDGET-001', quantity: 2, price: 49.99 }],
              createdAt: new Date('2026-03-15T10:00:00Z')
            }
          });
        },
        'order with id 999 does not exist': async () => {
          await db.orders.deleteMany({ where: { id: '999' } });
        }
      }
    });
 
    return verifier.verifyProvider();
  });
});

Pact Broker: Contract Registry

The Pact Broker is a central registry for contracts. Consumers publish their contracts after tests pass. Providers fetch and verify against all active consumer contracts. The broker tracks which versions are compatible and powers the can-i-deploy check.

# CI: Consumer publishes contract after tests pass
- name: Publish pact
  run: |
    npx pact-broker publish pacts/ \
      --consumer-app-version=$GITHUB_SHA \
      --branch=$GITHUB_REF_NAME \
      --broker-base-url=$PACT_BROKER_URL
 
# CI: Provider checks compatibility before deploy
- name: Can I deploy?
  run: |
    npx pact-broker can-i-deploy \
      --pacticipant=OrdersAPI \
      --version=$GITHUB_SHA \
      --to-environment=production \
      --broker-base-url=$PACT_BROKER_URL

The can-i-deploy command prevents deploying a provider version that breaks any deployed consumer.

Handling Backwards-Compatible Changes

Not all API changes are breaking. Pact helps distinguish safe from unsafe changes:

// SAFE: Provider adds optional field
// Consumer expects: { id, status, total }
// Provider returns: { id, status, total, createdAt }
// Result: consumer ignores unknown fields — no breakage
 
// UNSAFE: Provider removes field consumer expects
// Consumer expects: { id, status, total }
// Provider returns: { id, status }
// Result: consumer code breaks accessing .total
 
// SAFE migration pattern: add new field, keep old field
const response = {
  id: order.id,
  status: order.status,
  // Old field kept for backward compatibility
  orderStatus: order.status,
  total: order.total
};
// Remove old field only after all consumers have migrated

Schema Evolution With Versioned Endpoints

When breaking changes are unavoidable, use versioned endpoints and run both in parallel during migration:

const express = require('express');
const app = express();
 
// v1: Original shape (kept for existing consumers)
app.get('/v1/orders/:id', async (req, res) => {
  const order = await getOrder(req.params.id);
  res.json({
    orderId: order.id,       // v1 field name
    orderStatus: order.status,
    orderTotal: order.total
  });
});
 
// v2: New shape (new consumers use this)
app.get('/v2/orders/:id', async (req, res) => {
  const order = await getOrder(req.params.id);
  res.json({
    id: order.id,            // v2 field name
    status: order.status,
    total: order.total,
    currency: order.currency // new field
  });
});

Consumer tests pin to a specific version. When all consumers have migrated to v2, the Pact broker shows no active consumers on v1, and you can safely decommission it.

Contract Testing vs Integration Testing

AspectContract TestingIntegration Testing
SpeedVery fast (seconds)Slow (minutes)
ScopeAPI boundary onlyFull system
MockingProvider mockedNothing mocked
Primary valueCatches breaking API changesCatches runtime workflow failures
Failure feedbackImmediate, specificSlow, broad

Use both. Contract tests catch incompatibilities early and give fast feedback in CI. Integration tests verify complete workflows end-to-end. The contract test layer eliminates most of the slow, flaky integration tests that exist only to catch interface mismatches.

Key Takeaways

  • Consumer-driven contracts invert the traditional model: clients define what they expect, providers prove they deliver it
  • Pact generates contract files from consumer tests that run against a mock provider — no server required
  • Provider verification reads consumer contracts and validates the real implementation against them
  • The Pact Broker tracks compatibility across service versions and enables can-i-deploy checks in CI
  • Adding optional fields to API responses is safe; removing or renaming required fields breaks consumers
  • Run consumer tests on every PR; run provider verification before every deploy
  • Contract tests are 10-100x faster than integration tests and eliminate most coordination overhead between teams

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro