Claude Code for Contract Testing: Pact, Consumer-Driven Contracts, and API Compatibility — Claude Skills 360 Blog
Blog / Testing / Claude Code for Contract Testing: Pact, Consumer-Driven Contracts, and API Compatibility
Testing

Claude Code for Contract Testing: Pact, Consumer-Driven Contracts, and API Compatibility

Published: July 16, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free