Advanced Consumer-Driven Contract Testing with Pact for Microservices

Advanced Consumer-Driven Contract Testing with Pact for Microservices

Consumer-driven contract testing with Pact is well-documented at the basics level: consumer writes expectations, provider verifies them. But real multi-team microservices environments hit edge cases that basic tutorials don't cover. This guide focuses on the advanced patterns: provider states, message contracts, Pact Broker integration, and making can-i-deploy part of your release gate.

The Problem Pact Solves at Scale

When 10 services communicate with each other, integration testing becomes a combinatorial nightmare. Testing service A requires service B which requires service C — the dependency chain never ends.

Contract testing breaks this by replacing integration tests with:

  1. A consumer pact that records what the consumer needs from the provider
  2. A provider verification that the provider satisfies those pacts

Neither side needs to run the other's service. Teams deploy independently, and Pact Broker tracks which versions are compatible.

Advanced Provider States

Provider states are setup functions that configure the provider's data before each interaction is verified. Basic examples use simple strings. Advanced usage requires thinking carefully about state isolation and state cleanup.

Parameterized Provider States

Pass parameters to provider states for data-driven setup:

Consumer pact with state parameters:

// order-service/tests/contract/inventory.pact.test.js
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, integer, string } = MatchersV3;

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'InventoryService',
  dir: './pacts',
});

it('checks stock for a specific product', () => {
  return provider
    .given('product exists with stock', {
      product_id: 'prod-xyz',   // Parameters passed to provider state
      quantity: 50,
    })
    .uponReceiving('a stock check for product prod-xyz')
    .withRequest({
      method: 'GET',
      path: '/stock/prod-xyz',
    })
    .willRespondWith({
      status: 200,
      body: {
        product_id: string('prod-xyz'),
        available: integer(50),
        reserved: integer(0),
      },
    })
    .executeTest(async (mockServer) => {
      const client = new InventoryClient(mockServer.url);
      const stock = await client.getStock('prod-xyz');
      
      expect(stock.available).toBe(50);
    });
});

Provider state handler with parameters:

// inventory-service/tests/contract/verify.test.js
const { Verifier } = require('@pact-foundation/pact');
const { db, seedData } = require('../helpers/test-db');

describe('Pact Verification', () => {
  it('validates OrderService pacts', () => {
    return new Verifier({
      provider: 'InventoryService',
      providerBaseUrl: 'http://localhost:3002',
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      
      stateHandlers: {
        'product exists with stock': async (parameters) => {
          // parameters = { product_id: 'prod-xyz', quantity: 50 }
          await db.query(
            'INSERT INTO products (id, available_quantity) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET available_quantity = $2',
            [parameters.product_id, parameters.quantity]
          );
        },
        
        'product does not exist': async (parameters) => {
          await db.query(
            'DELETE FROM products WHERE id = $1',
            [parameters.product_id]
          );
        },
        
        'product is out of stock': async (parameters) => {
          await db.query(
            'INSERT INTO products (id, available_quantity) VALUES ($1, 0) ON CONFLICT (id) DO UPDATE SET available_quantity = 0',
            [parameters.product_id]
          );
        },
      },
      
      // Teardown between interactions
      afterEach: async () => {
        await db.query('TRUNCATE products CASCADE');
      },
      
      publishVerificationResult: process.env.CI === 'true',
      providerVersion: process.env.GIT_COMMIT || '1.0.0',
      providerVersionBranch: process.env.GIT_BRANCH || 'main',
    }).verifyProvider();
  });
});

State Setup vs State Teardown

A common mistake is not cleaning up state between interactions. If pact A sets up a product and pact B assumes no products exist, B will fail:

stateHandlers: {
  'no products exist': async () => {
    await db.query('TRUNCATE products');
  },
  
  'multiple products exist': async () => {
    await db.query('TRUNCATE products');  // Always clean first
    await db.query(`
      INSERT INTO products (id, name, price) VALUES
        ('prod-001', 'Widget', 9.99),
        ('prod-002', 'Gadget', 19.99),
        ('prod-003', 'Doohickey', 4.99)
    `);
  },
},

// Or use beforeEach/afterEach for automatic cleanup
beforeEach: async () => {
  await db.query('BEGIN');  // Start transaction
},
afterEach: async () => {
  await db.query('ROLLBACK');  // Roll back all changes
},

Message Contract Testing

HTTP request/response contracts are well covered. Message contracts (for event-driven services) are less common but equally important.

Consumer Side: Asserting Message Structure

// notification-service/tests/contract/order-events.pact.test.js
const { PactV3, MatchersV3, MessageConsumerPact } = require('@pact-foundation/pact');
const { like, string, integer, timestamp } = MatchersV3;
const path = require('path');

const messagePact = new MessageConsumerPact({
  consumer: 'NotificationService',
  provider: 'OrderService',
  dir: path.resolve('./pacts'),
});

describe('Order event contract', () => {
  it('handles order.confirmed events', async () => {
    await messagePact
      .given('an order has been confirmed')
      .expectsToReceive('an order.confirmed event')
      .withContent({
        event_type: string('order.confirmed'),
        order_id: like('ord-123'),
        customer_id: like('cust-456'),
        total: like(99.99),
        items: [
          {
            product_id: like('prod-789'),
            quantity: integer(2),
            price: like(49.99),
          },
        ],
        confirmed_at: timestamp("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
      })
      .withMetadata({ contentType: 'application/json' })
      .verify(async (message) => {
        // This is the actual consumer code that processes the message
        const notificationService = new NotificationService();
        const result = await notificationService.handleOrderConfirmed(message.contents);
        
        expect(result.notificationSent).toBe(true);
        expect(result.channel).toBe('email');
      });
  });
  
  it('handles order.cancelled events', async () => {
    await messagePact
      .given('an order has been cancelled')
      .expectsToReceive('an order.cancelled event')
      .withContent({
        event_type: string('order.cancelled'),
        order_id: like('ord-123'),
        customer_id: like('cust-456'),
        reason: like('customer request'),
        cancelled_at: timestamp("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
      })
      .verify(async (message) => {
        const notificationService = new NotificationService();
        const result = await notificationService.handleOrderCancelled(message.contents);
        
        expect(result.notificationSent).toBe(true);
        expect(result.refundInitiated).toBe(true);
      });
  });
});

Provider Side: Publishing Messages That Satisfy Contracts

// order-service/tests/contract/verify-message-pacts.test.js
const { MessageProviderPact } = require('@pact-foundation/pact');
const { EventEmitter } = require('../../src/events/event-emitter');

describe('Message Pact Verification', () => {
  it('satisfies NotificationService message contracts', async () => {
    const pact = new MessageProviderPact({
      messageProviders: {
        'an order has been confirmed': async () => {
          // Return the actual message your service produces
          return {
            contents: {
              event_type: 'order.confirmed',
              order_id: 'ord-test-001',
              customer_id: 'cust-test-001',
              total: 99.99,
              items: [
                { product_id: 'prod-test-001', quantity: 2, price: 49.99 },
              ],
              confirmed_at: new Date().toISOString(),
            },
            metadata: { contentType: 'application/json' },
          };
        },
        
        'an order has been cancelled': async () => {
          return {
            contents: {
              event_type: 'order.cancelled',
              order_id: 'ord-test-002',
              customer_id: 'cust-test-002',
              reason: 'customer request',
              cancelled_at: new Date().toISOString(),
            },
            metadata: { contentType: 'application/json' },
          };
        },
      },
      
      provider: 'OrderService',
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      publishVerificationResult: process.env.CI === 'true',
      providerVersion: process.env.GIT_COMMIT || '1.0.0',
    });
    
    return pact.verify();
  });
});

Pact Broker Setup and Workflow

The Pact Broker is the central store for pacts and verification results. Without it, sharing pacts across teams is manual and error-prone.

Running Pact Broker Locally

# docker-compose.pact.yml
version: '3'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: pact
      POSTGRES_DB: pact
  
  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres/pact
    depends_on:
      - postgres
docker-compose -f docker-compose.pact.yml up -d

Access the Pact Broker UI at http://localhost:9292.

Publishing Pacts from CI

# In consumer CI pipeline
pact-broker publish ./pacts \
  --consumer-app-version "$GIT_COMMIT" \
  --branch "$GIT_BRANCH" \
  --broker-base-url "$PACT_BROKER_URL" \
  --broker-token "$PACT_BROKER_TOKEN"

Verifying Against Broker in CI

# In provider CI pipeline  
pact-broker verify \
  --provider "InventoryService" \
  --provider-base-url "http://localhost:3002" \
  --broker-base-url "$PACT_BROKER_URL" \
  --broker-token "$PACT_BROKER_TOKEN" \
  --provider-version "$GIT_COMMIT" \
  --provider-version-branch "$GIT_BRANCH" \
  --publish-verification-results

can-i-deploy: The Release Gate

can-i-deploy is the tool that makes contract testing actionable. Before deploying a service, check whether the version you're about to deploy is compatible with all versions currently in production.

# Before deploying order-service v2.3.0 to production:
pact-broker can-i-deploy \
  --pacticipant "OrderService" \
  --version "$GIT_COMMIT" \
  --to-environment production \
  --broker-base-url "$PACT_BROKER_URL" \
  --broker-token "$PACT_BROKER_TOKEN"

# Output if safe:
# Computer says yes ✔
# 
# CONSUMER         | C.VERSION | PROVIDER          | P.VERSION | SUCCESS?
# OrderService     | abc123    | InventoryService  | def456    | true
# OrderService     | abc123    | PaymentService    | ghi789    | true
# OrderService     | abc123    | NotificationSvc   | jkl012    | true

# Output if unsafe:
# Computer says no ✗
# 
# CONSUMER         | C.VERSION | PROVIDER          | P.VERSION | SUCCESS?
# OrderService     | abc123    | PaymentService    | ghi789    | false
# 
# The verification for OrderService/abc123 and PaymentService/ghi789 has failed

Integrate into your deployment pipeline:

# .github/workflows/deploy.yml
jobs:
  deploy:
    steps:
      - name: Check pact compatibility
        run: |
          pact-broker can-i-deploy \
            --pacticipant "$SERVICE_NAME" \
            --version "$GITHUB_SHA" \
            --to-environment production \
            --broker-base-url "$PACT_BROKER_URL" \
            --broker-token "$PACT_BROKER_TOKEN"
      
      - name: Deploy to production
        if: success()
        run: kubectl apply -f k8s/production/

If can-i-deploy fails, the deployment step is skipped — no unsafe deployment possible.

Multi-Consumer Provider Verification

When multiple consumers use the same provider, verify all of them in CI:

// user-service/tests/contract/verify-all-consumers.test.js
const { Verifier } = require('@pact-foundation/pact');

describe('User Service - All Consumer Verifications', () => {
  it('satisfies all consumer contracts from broker', async () => {
    return new Verifier({
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3003',
      
      // Pull ALL pacts from broker, not just one consumer
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      consumerVersionSelectors: [
        { mainBranch: true },       // latest main branch from each consumer
        { deployedOrReleased: true }, // currently deployed versions
      ],
      
      stateHandlers: { /* ... */ },
      
      publishVerificationResult: true,
      providerVersion: process.env.GIT_COMMIT,
      providerVersionBranch: process.env.GIT_BRANCH,
    }).verifyProvider();
  });
});

This single verification run covers:

  • All consumers that have pacts in the broker
  • Both their current main branch version AND whatever is deployed in production

Handling Pact Failures in Team Workflows

When a provider verification fails, the right action depends on who made the breaking change.

Provider broke a consumer's contract:

  1. Provider team checks which consumer pact failed
  2. Either revert the breaking change or negotiate a new contract with the consumer team
  3. Consumer team updates their pact if the API change was intentional

Consumer changed their expectations:

  1. Consumer team publishes a new pact version
  2. Provider team runs verification against the new pact
  3. If the new expectations can't be satisfied, negotiate

Pending pacts (new consumer not yet deployed):

enablePending: true,  // Don't fail verification for pending pacts

Pending pacts allow a new consumer to publish pacts before the provider has verified them, without blocking the provider's CI.

Key Takeaways

  • Use parameterized provider states for data-driven test setup — avoid hardcoded product IDs in pacts
  • Always clean up state between interactions — use transaction rollback for fast isolation
  • Write message contracts for event-driven services, not just HTTP APIs
  • Set up Pact Broker early — sharing pact files by hand doesn't scale past 3 services
  • Make can-i-deploy a hard gate in your deployment pipeline — it's the whole point of contract testing
  • Use pending pacts to allow new consumers to onboard without blocking providers
  • Verify all consumer pact versions in provider CI — both main branch and currently deployed

Read more

Start now free