Consumer-Driven Contract Testing: Complete Guide with Examples

Consumer-Driven Contract Testing: Complete Guide with Examples

Consumer-driven contract testing flips the typical API design relationship. Instead of providers exposing everything they can and consumers using what they need, consumers specify exactly what they require and providers verify they satisfy those requirements. The result is APIs that evolve safely, without surprise breakages, even in organizations with dozens of independent services.

The Core Insight

In traditional microservice development, this conversation happens frequently:

Provider team: "We're refactoring the user service — removing the legacy_id field nobody uses." Order team, two days later: "Production is broken — we used legacy_id in our order service." Payment team, same day: "Also broken — same field."

Nobody told the provider team about these dependencies. The legacy_id field looked unused from the provider's perspective — it was used by consumers in ways the provider couldn't see.

Consumer-driven contracts make these dependencies explicit and machine-verifiable. Before the provider team removed legacy_id, their provider verification step would have failed with a clear error: "order-service pact requires field legacy_id." The breaking change is caught before deployment.

The Workflow

Step 1 — Consumer writes tests:

The consumer team writes tests against a Pact mock provider. These tests define what the consumer actually calls and what it expects back. Running the tests generates a pact file.

// Consumer test (order-service)
await provider
  .addInteraction({
    states: [{ description: 'user exists with ID 42' }],
    uponReceiving: 'a request for user 42',
    withRequest: { method: 'GET', path: '/users/42' },
    willRespondWith: {
      status: 200,
      body: {
        id: like(42),
        name: like('Alice'),
        email: like('alice@example.com'),
        // Note: legacy_id is NOT included — consumer doesn't use it
      },
    },
  })
  .executeTest(async (mockserver) => {
    const client = new UserClient(mockserver.url);
    const user = await client.getUser(42);
    expect(user.id).toBeDefined();
    expect(user.email).toContain('@');
  });

Step 2 — Consumer publishes the pact:

pact-broker publish ./pacts \
  --broker-base-url https://pact.yourcompany.com \
  --broker-token $PACT_BROKER_TOKEN \
  --consumer-app-version <span class="hljs-variable">$GIT_SHA \
  --tag main

Step 3 — Provider verifies:

The provider team fetches all pact files from the broker and verifies their implementation satisfies each one.

// Provider verification (user-service)
const verifier = new Verifier({
  provider: 'user-service',
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: 'https://pact.yourcompany.com',
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
  publishVerificationResult: true,
  providerVersion: process.env.GIT_SHA,
  stateHandlers: {
    'user exists with ID 42': async () => {
      await db.users.upsert({ id: 42, name: 'Alice', email: 'alice@example.com', legacy_id: 'old-123' });
    },
  },
});

await verifier.verifyProvider();

If the user service tries to remove legacy_id from its response, verification still passes (because the consumer contract doesn't include legacy_id). But if the user service renames email to emailAddress, verification fails — the consumer expected email.

Step 4 — can-i-deploy gates deployment:

pact-broker can-i-deploy \
  --pacticipant user-service \
  --version $GIT_SHA \
  --to-environment production \
  --broker-base-url https://pact.yourcompany.com \
  --broker-token <span class="hljs-variable">$PACT_BROKER_TOKEN

If any consumer's pact hasn't been verified against this version of user-service, can-i-deploy exits non-zero and blocks the deployment.

Setting Up the Pact Broker

Self-hosted (Docker):

# docker-compose.yml
version: '3'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: pact_password
      POSTGRES_DB: pact_db

  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgresql://postgres:pact_password@postgres/pact_db
      PACT_BROKER_BASIC_AUTH_USERNAME: admin
      PACT_BROKER_BASIC_AUTH_PASSWORD: $PACT_BROKER_PASSWORD
      PACT_BROKER_PUBLIC_HEARTBEAT: "true"
    depends_on:
      - postgres

PactFlow (managed SaaS): Recommended for teams that don't want to maintain broker infrastructure. Adds features like bidirectional contracts, branch-based webhooks, and better RBAC.

Consumer Version Selectors

When the provider fetches pacts to verify, it needs to know which consumer versions to verify against. Consumer version selectors control this:

consumerVersionSelectors: [
  { mainBranch: true },          // Latest pact from consumer's main branch
  { deployedOrReleased: true },  // Pacts from currently deployed versions
  { branch: 'feature/new-ui' }  // Specific branch (for testing a WIP)
]

mainBranch: true: The most important selector. Verify against what's currently on the consumer's main branch. This is the version that will deploy to production.

deployedOrReleased: true: Verify against versions currently deployed to any environment. Prevents breaking consumers that are already live.

Without the right selectors, provider teams might not verify against a consumer version that matters, and a breaking change could slip through.

Recording Deployments

For deployedOrReleased: true to work, you need to record deployments to the Pact Broker:

# After deploying consumer to staging
pact-broker record-deployment \
  --pacticipant order-service \
  --version <span class="hljs-variable">$GIT_SHA \
  --environment staging

<span class="hljs-comment"># After deploying to production
pact-broker record-deployment \
  --pacticipant order-service \
  --version <span class="hljs-variable">$GIT_SHA \
  --environment production

The Pact Broker tracks which version of each service is deployed to which environment. This enables can-i-deploy to reason about "will deploying this version of user-service to production break any consumer that's currently in production?"

Handling Multiple Consumers

A single provider often has multiple consumers. The pact file is per consumer-provider pair:

pact-broker/
├── order-service → user-service.json
├── payment-service → user-service.json
├── notification-service → user-service.json
└── reporting-service → user-service.json

The provider verifies all of them. If user-service wants to remove a field:

  • Check all pact files that include that field
  • If any consumer still uses it, you can't remove it yet
  • Coordinate with consuming teams to update their contracts first, then remove the field

This is the key benefit: the dependency is explicit. "Who uses legacy_id?" is answerable by checking pact files.

Pending Pacts and WIP Mode

When a new consumer adds a pact for an existing provider, the provider's CI breaks immediately — because they can't verify a contract they've never seen before. This creates friction between teams.

Pending pacts solve this: a new pact is in "pending" state until the provider explicitly verifies it for the first time. Provider CI doesn't fail for pending pacts.

const verifier = new Verifier({
  // ...
  enablePending: true,          // New pacts don't break provider CI
  includeWipPactsSince: '2024-01-01',  // Also verify pacts created since this date
});

WIP (Work In Progress) pacts: Similar — pacts that haven't been verified yet are included in WIP mode. Provider verification reports on them but doesn't fail CI.

This lets consumer teams add contracts without requiring immediate provider changes, and lets provider teams see what consumers expect without immediate pressure.

Webhook-Driven Verification

Instead of running provider verification on every provider commit (which would be slow for providers with many consumers), configure webhooks:

  1. Consumer publishes a new pact → Pact Broker triggers a webhook
  2. Webhook calls provider's CI API to trigger a verification run
  3. Verification results posted back to broker
{
  "events": [
    {
      "name": "contract_published_with_content_change"
    }
  ],
  "request": {
    "method": "POST",
    "url": "https://ci.example.com/api/builds/trigger",
    "headers": {
      "Authorization": "Bearer ${user.token}"
    },
    "body": {
      "pipeline": "user-service-contract-verification",
      "sha": "${pactbroker.providerVersionNumber}",
      "pactUrl": "${pactbroker.pactUrl}"
    }
  }
}

This approach verifies contracts only when they change, not on every provider commit.

Polyglot: Multiple Languages

Pact supports consumers and providers in different languages. The pact file format is language-agnostic JSON. A TypeScript consumer can publish a contract that a Go provider verifies.

Pact libraries by language:

  • JavaScript/TypeScript: @pact-foundation/pact
  • Java/Kotlin: au.com.dius.pact
  • Go: pact-go
  • Python: pact-python
  • Ruby: pact-ruby
  • .NET: PactNet
  • PHP: pact-php
  • Rust: pact-reference (native Rust core)

All Pact libraries share the same pact file format and broker protocol.

Contract Testing for Message-Based Interactions

Contract testing isn't limited to HTTP. Pact supports async message contracts for Kafka, SQS, SNS, and other message brokers:

// Consumer: expects this message format from order-events topic
await messagePact
  .given('an order was created')
  .expectsToReceive('an order created event')
  .withContent({
    orderId: like('order-123'),
    userId: like(42),
    total: like(99.99),
    status: 'CREATED',
  })
  .verify(async (message) => {
    const handler = new OrderCreatedHandler();
    await handler.handle(JSON.parse(message.contents));
    expect(handler.processedOrderId).toBe('order-123');
  });

The message pact is published and the provider verifies that when createOrder() is called, it publishes a message matching the contract.

CI/CD Pipeline Integration

Consumer pipeline:

# .github/workflows/consumer.yml
- name: Consumer contract tests
  run: npm run test:pact

- 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 \
      --tag $GITHUB_REF_NAME

- name: Can I deploy?
  run: |
    npx pact-broker can-i-deploy \
      --pacticipant order-service \
      --version $GITHUB_SHA \
      --to-environment production \
      --broker-base-url $PACT_BROKER_URL \
      --broker-token $PACT_BROKER_TOKEN

- name: Record deployment
  if: github.ref == 'refs/heads/main'
  run: |
    npx pact-broker record-deployment \
      --pacticipant order-service \
      --version $GITHUB_SHA \
      --environment production \
      --broker-base-url $PACT_BROKER_URL \
      --broker-token $PACT_BROKER_TOKEN

Provider pipeline:

- name: Start service
  run: npm start &
  
- name: Verify pacts
  run: npm run test:pact:verify
  env:
    PACT_BROKER_URL: ${{ vars.PACT_BROKER_URL }}
    PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
    PROVIDER_VERSION: ${{ github.sha }}
    
- name: Can I deploy?
  run: |
    npx pact-broker can-i-deploy \
      --pacticipant user-service \
      --version $GITHUB_SHA \
      --to-environment production \
      --broker-base-url $PACT_BROKER_URL \
      --broker-token $PACT_BROKER_TOKEN

Summary

Consumer-driven contract testing:

  1. Consumers define contracts — only what they actually use, not what the provider exposes
  2. Pact files encode these contracts as machine-readable specifications
  3. Pact Broker distributes contracts between teams and tracks verification status
  4. Providers verify all consumer contracts independently, before deployment
  5. can-i-deploy blocks unsafe deployments by checking all verifications are current
  6. Record deployments so the broker knows what's live in each environment

For HelpMeTest, contract testing complements continuous monitoring. Contract tests verify API shapes at deploy time. HelpMeTest verifies end-to-end application behavior continuously in production — catching issues that contract tests don't cover: infrastructure failures, data inconsistencies, and timing-sensitive bugs that only appear under real load.

The investment in consumer-driven contract testing pays off at scale: 10+ services, independent deployment schedules, and multiple teams who can't coordinate every API change. Below that scale, integration testing is often sufficient.

Read more