Front-end Engineering Lab
PatternsTesting Strategies

Contract Testing

Consumer-driven contract testing with Pact for API compatibility

Contract Testing

Contract testing ensures that services can communicate with each other by verifying that the API provider meets the expectations of its consumers. This is especially valuable in microservices architecture.

What is Contract Testing?

Traditional API Testing:

  • Consumer tests against actual provider
  • Provider can break consumer without knowing
  • Integration tests are slow and brittle

Contract Testing:

  • Consumer defines expectations (contract)
  • Provider verifies it meets contract
  • Fast, independent testing
  • Early detection of breaking changes

How Pact Works

1. Consumer creates contract (Pact file)

2. Contract shared with Provider

3. Provider verifies it satisfies contract

4. Both sides deploy safely

Pact Setup

Installation

npm install --save-dev @pact-foundation/pact

Consumer Side (Frontend)

Writing Consumer Tests

// tests/contract/user-service.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { getUserProfile, updateUserProfile } from '@/api/user-service';

const { like, eachLike, integer, string } = MatchersV3;

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

describe('User Service Contract', () => {
  describe('GET /api/users/:id', () => {
    it('returns user profile', async () => {
      await provider
        .given('user 123 exists')
        .uponReceiving('a request for user 123')
        .withRequest({
          method: 'GET',
          path: '/api/users/123',
          headers: {
            'Authorization': like('Bearer token123'),
          },
        })
        .willRespondWith({
          status: 200,
          headers: {
            'Content-Type': 'application/json',
          },
          body: {
            id: like(123),
            name: like('John Doe'),
            email: like('john@example.com'),
            role: like('admin'),
            createdAt: like('2024-01-01T00:00:00Z'),
          },
        })
        .executeTest(async (mockServer) => {
          // Call your actual API client
          const user = await getUserProfile(mockServer.url, 123);
          
          expect(user.id).toBe(123);
          expect(user.name).toBeDefined();
          expect(user.email).toBeDefined();
        });
    });

    it('handles user not found', async () => {
      await provider
        .given('user 999 does not exist')
        .uponReceiving('a request for non-existent user')
        .withRequest({
          method: 'GET',
          path: '/api/users/999',
        })
        .willRespondWith({
          status: 404,
          body: {
            error: like('User not found'),
          },
        })
        .executeTest(async (mockServer) => {
          await expect(
            getUserProfile(mockServer.url, 999)
          ).rejects.toThrow('User not found');
        });
    });
  });

  describe('POST /api/users/:id', () => {
    it('updates user profile', async () => {
      await provider
        .given('user 123 exists')
        .uponReceiving('a request to update user 123')
        .withRequest({
          method: 'POST',
          path: '/api/users/123',
          headers: {
            'Content-Type': 'application/json',
          },
          body: {
            name: like('Jane Doe'),
            email: like('jane@example.com'),
          },
        })
        .willRespondWith({
          status: 200,
          body: {
            id: like(123),
            name: like('Jane Doe'),
            email: like('jane@example.com'),
            updatedAt: like('2024-01-09T00:00:00Z'),
          },
        })
        .executeTest(async (mockServer) => {
          const updated = await updateUserProfile(mockServer.url, 123, {
            name: 'Jane Doe',
            email: 'jane@example.com',
          });
          
          expect(updated.name).toBe('Jane Doe');
          expect(updated.email).toBe('jane@example.com');
        });
    });
  });

  describe('GET /api/users', () => {
    it('returns list of users', async () => {
      await provider
        .given('multiple users exist')
        .uponReceiving('a request for all users')
        .withRequest({
          method: 'GET',
          path: '/api/users',
          query: {
            page: like('1'),
            limit: like('10'),
          },
        })
        .willRespondWith({
          status: 200,
          body: {
            users: eachLike({
              id: integer(1),
              name: string('John Doe'),
              email: string('john@example.com'),
            }),
            total: like(100),
            page: like(1),
          },
        })
        .executeTest(async (mockServer) => {
          const result = await getUsers(mockServer.url, { page: 1, limit: 10 });
          
          expect(result.users).toBeInstanceOf(Array);
          expect(result.users[0]).toHaveProperty('id');
          expect(result.users[0]).toHaveProperty('name');
          expect(result.total).toBeDefined();
        });
    });
  });
});

Matchers

import { MatchersV3 } from '@pact-foundation/pact';

const {
  like,        // Type matching
  eachLike,    // Array with type matching
  integer,     // Integer type
  decimal,     // Decimal type
  string,      // String type
  boolean,     // Boolean type
  regex,       // Regex matching
  iso8601DateTime, // ISO datetime
  uuid,        // UUID format
} = MatchersV3;

// Examples
body: {
  id: integer(123),
  email: regex('john@example.com', /^[\w.]+@[\w.]+$/),
  createdAt: iso8601DateTime('2024-01-09T10:00:00Z'),
  uuid: uuid('550e8400-e29b-41d4-a716-446655440000'),
  tags: eachLike('tag1'),
  metadata: like({ key: 'value' }),
}

Provider Side (Backend)

Verifying Contracts

// tests/contract/verify-pacts.test.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';

describe('Pact Verification', () => {
  it('validates contracts from consumers', async () => {
    const verifier = new Verifier({
      providerBaseUrl: 'http://localhost:3001',
      
      // Load pact files
      pactUrls: [
        path.resolve(__dirname, '../../pacts/FrontendApp-UserService.json'),
      ],
      
      // Provider states setup
      stateHandlers: {
        'user 123 exists': async () => {
          // Setup: Create user 123 in test database
          await db.users.create({
            id: 123,
            name: 'John Doe',
            email: 'john@example.com',
          });
        },
        
        'user 999 does not exist': async () => {
          // Setup: Ensure user 999 doesn't exist
          await db.users.delete({ id: 999 });
        },
        
        'multiple users exist': async () => {
          // Setup: Create multiple users
          await db.users.createMany([
            { id: 1, name: 'User 1', email: 'user1@example.com' },
            { id: 2, name: 'User 2', email: 'user2@example.com' },
            // ... more users
          ]);
        },
      },
      
      // Tear down after each verification
      afterEach: async () => {
        await db.users.deleteAll();
      },
    });

    await verifier.verifyProvider();
  });
});

Pact Broker (Sharing Contracts)

Setup Pact Broker

# docker-compose.yml
version: '3'

services:
  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - '9292:9292'
    environment:
      PACT_BROKER_DATABASE_URL: postgres://postgres:password@postgres/pact_broker
    depends_on:
      - postgres

  postgres:
    image: postgres:14
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: pact_broker

Publish Contracts (Consumer)

// package.json
{
  "scripts": {
    "pact:publish": "pact-broker publish ./pacts --consumer-app-version=$GIT_COMMIT --broker-base-url=http://localhost:9292"
  }
}
# Publish pacts to broker
npm run pact:publish

Verify from Broker (Provider)

const verifier = new Verifier({
  providerBaseUrl: 'http://localhost:3001',
  
  // Fetch from Pact Broker
  pactBrokerUrl: 'http://localhost:9292',
  provider: 'UserService',
  
  // Only verify pacts from main branch
  consumerVersionSelectors: [
    { mainBranch: true },
    { deployedOrReleased: true },
  ],
  
  // Publish verification results
  publishVerificationResult: true,
  providerVersion: process.env.GIT_COMMIT,
});

CI/CD Integration

Consumer Pipeline

# .github/workflows/contract-tests.yml
name: Contract Tests (Consumer)

on: [push, pull_request]

jobs:
  consumer:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run consumer tests
        run: npm run test:contract
      
      - name: Publish pacts
        if: github.ref == 'refs/heads/main'
        run: |
          npm run pact:publish
        env:
          GIT_COMMIT: ${{ github.sha }}
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}

Provider Pipeline

# .github/workflows/verify-contracts.yml
name: Verify Contracts (Provider)

on: [push, pull_request]

jobs:
  provider:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Start provider
        run: npm start &
      
      - name: Wait for provider
        run: npx wait-on http://localhost:3001
      
      - name: Verify contracts
        run: npm run pact:verify
        env:
          GIT_COMMIT: ${{ github.sha }}
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}

Best Practices

  1. Test Behavior, Not Data: Use matchers for flexible contracts
  2. Version Contracts: Track which consumers use which versions
  3. Provider States: Setup data needed for each interaction
  4. Use Pact Broker: Central place for contracts
  5. Block Breaking Changes: Fail CI if contract broken
  6. Tag Versions: Tag production/staging contracts
  7. Document Contracts: They serve as living API documentation

Common Pitfalls

Over-specifying contracts: Too rigid, breaks often
Use type matchers, not exact values

Testing implementation: Checking internal details
Test API contract only

No provider states: Tests fail randomly
Setup proper test data

Duplicate logic: Repeating provider code
Reuse actual API client code

When to Use Contract Testing

Use contract testing when:

  • Microservices architecture
  • Multiple teams/services
  • API versioning is complex
  • Want fast, independent tests
  • Need early breaking change detection

Skip when:

  • Monolithic application
  • Single team owning everything
  • Simple API with few changes
  • API is public/third-party

Contract vs E2E Testing

AspectContract TestsE2E Tests
SpeedFastSlow
ReliabilityHighCan be flaky
ScopeAPI contractFull system
WhenOn every commitPre-deployment
IsolationHighLow

Best Practice: Use both! Contract tests for API compatibility, E2E for critical flows.

Contract testing catches breaking changes early—before they reach production and before they break other teams.

On this page