Engineering Playbook

Contract Testing

Pact, consumer-driven contracts, and microservice testing.

Contract Testing

Contract testing ensures that services can communicate with each other without actually running them together. It's a crucial testing strategy for microservices that provides fast, reliable integration testing without the complexity of end-to-end tests.

The Problem with Traditional Integration Testing

E2E Testing Challenges

  • Slow: Requires spinning up entire system
  • Brittle: Tests break when unrelated services change
  • Expensive: Complex infrastructure and maintenance
  • Late Feedback: Failures discovered late in the pipeline

Integration Testing Challenges

  • Environment Dependencies: Need all services running
  • Data Management: Complex test data setup
  • Flaky Tests: Network issues, timing problems
  • Parallel Execution: Difficult to run tests concurrently

Contract Testing Solution

Contract testing breaks the problem into two parts:

  1. Consumer Tests: Verify consumer expectations against a mock provider
  2. Provider Tests: Verify provider implementation against consumer expectations

The Flow


Pact: The Contract Testing Framework

Pact is the most popular contract testing framework with implementations for multiple languages.

Key Concepts

1. Consumer-Driven Contracts

The consumer defines the contract based on its needs:

// Consumer test (React app consuming API)
import { pactWith } from 'jest-pact';
import { getUser } from './api-service';

pactWith({ consumer: 'web-app', provider: 'user-api' }, provider => {
  test('returns user data', async () => {
    await provider.addInteraction({
      state: 'user exists',
      uponReceiving: 'request for user 123',
      withRequest: {
        method: 'GET',
        path: '/users/123',
        headers: { 'Accept': 'application/json' }
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: 123,
          name: 'John Doe',
          email: 'john@example.com',
          active: true
        }
      }
    });

    const user = await getUser(123, provider.mockService.baseUrl);
    expect(user).toEqual({
      id: 123,
      name: 'John Doe',
      email: 'john@example.com',
      active: true
    });
  });
});

2. Provider Verification

The provider verifies it can fulfill all consumer contracts:

// Provider test (Express API)
import { Verifier } from '@pact-foundation/pact';
import express from 'express';
import userRoutes from './routes/users';

describe('User API Provider Verification', () => {
  test('validates contracts', async () => {
    const app = express();
    app.use('/users', userRoutes);
    
    const verifier = new Verifier({
      provider: 'user-api',
      providerBaseUrl: 'http://localhost:8080',
      pactUrls: ['./pacts/web-app-user-api.json'],
      stateHandlers: {
        'user exists': () => {
          // Setup test data for this state
          return Promise.resolve();
        }
      }
    });

    const output = await verifier.verifyProvider();
    console.log(output);
  });
});

Contract Testing Patterns

1. Request-Response Contracts

Simple GET Contract

{
  "consumer": "web-app",
  "provider": "user-api",
  "interactions": [
    {
      "description": "request for user by ID",
      "request": {
        "method": "GET",
        "path": "/users/123",
        "headers": {
          "Accept": "application/json"
        }
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json"
        },
        "body": {
          "id": 123,
          "name": "John Doe",
          "email": "john@example.com",
          "active": true
        },
        "matchingRules": {
          "$.body": {
            "jsonClass": "org.apache.commons.lang.builder.EqualsBuilder"
          },
          "$.body.id": {
            "match": "integer"
          },
          "$.body.name": {
            "match": "type"
          },
          "$.body.email": {
            "match": "regex",
            "regex": "^[\\w\\.\\-+]+@[\\w\\.-]+\\.[a-zA-Z]{2,}$"
          }
        }
      }
    }
  ]
}

POST Request Contract

test('creates new user', async () => {
  await provider.addInteraction({
    uponReceiving: 'create new user',
    withRequest: {
      method: 'POST',
      path: '/users',
      headers: {
        'Content-Type': 'application/json'
      },
      body: {
        name: 'Jane Doe',
        email: 'jane@example.com'
      }
    },
    willRespondWith: {
      status: 201,
      headers: {
        'Content-Type': 'application/json',
        'Location': 'http://localhost/users/456'
      },
      body: {
        id: 456,
        name: 'Jane Doe',
        email: 'jane@example.com',
        active: true,
        createdAt: '2023-01-01T00:00:00Z'
      },
      matchingRules: {
        "$.body.id": { "match": "integer" },
        "$.body.createdAt": { "match": "regex", "regex": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" }
      }
    }
  });
});

2. Message/Event Contracts

Async Message Contract

// Consumer expecting to receive user events
pactWith({ consumer: 'notification-service', provider: 'user-api' }, provider => {
  test('receives user created event', async () => {
    await provider.addInteraction({
      state: 'user created',
      uponReceiving: 'user created event',
      withRequest: {
        method: 'POST',
        path: '/messages',
        body: {
          "type": "UserCreated",
          "data": {
            "userId": 123,
            "name": "John Doe",
            "email": "john@example.com"
          }
        }
      },
      willRespondWith: {
        status: 200,
        body: {
          "success": true
        }
      }
    });
  });
});

Advanced Pact Features

1. State Management

Provider states help setup the right test data:

// Consumer test with state
await provider.addInteraction({
  state: 'user with orders exists',
  uponReceiving: 'request for user orders',
  withRequest: {
    method: 'GET',
    path: '/users/123/orders'
  },
  willRespondWith: {
    status: 200,
    body: [
      {
        id: 1,
        userId: 123,
        total: 99.99,
        status: 'completed'
      }
    ]
  }
});

// Provider state handler
const stateHandlers = {
  'user with orders exists': async () => {
    // Create test user with orders
    await userService.create({ id: 123, name: 'John Doe' });
    await orderService.create({ id: 1, userId: 123, total: 99.99 });
  },
  'user does not exist': async () => {
    // Ensure no user exists
    await userService.delete(123);
  }
};

2. Matching Rules

Instead of exact values, use flexible matching:

// Basic type matching
{
  "id": pact.like(123),           // Any integer
  "name": pact.like('John'),      // Any string
  "active": pact.like(true),      // Any boolean
  "metadata": pact.somethingLike({
    created: pact.like('2023-01-01T00:00:00Z')
  })
}

// Regex matching
{
  "email": pact.term({
    generate: 'test@example.com',
    matcher: '^[\\w\\.\\-+]+@[\\w\\.-]+\\.[a-zA-Z]{2,}$'
  }),
  "url": pact.term({
    generate: '/users/123',
    matcher: '/users/\\d+'
  })
}

// Array matching
{
  "items": pact.eachLike({
    id: pact.like(1),
    name: pact.like('Item'),
    min: 1  // At least one item
  })
}

3. Provider States with Parameters

// Consumer test
await provider.addInteraction({
  state: 'user with specific email exists',
  uponReceiving: 'request to update user',
  withRequest: {
    method: 'PUT',
    path: '/users/123',
    body: { email: 'newemail@example.com' }
  },
  willRespondWith: {
    status: 200,
    body: { success: true }
  }
});

// Provider with parameterized state
const stateHandlers = {
  'user with specific email exists': async (params) => {
    // params might contain user ID, email, etc.
    const { userId } = params;
    await userService.create({ 
      id: userId, 
      email: 'oldemail@example.com' 
    });
  }
};

Pact Broker Integration

Pact Broker is central to contract testing workflows:

Publishing Contracts

// CI/CD pipeline - Consumer
const publish = async () => {
  await new Publisher({
    pactBroker: 'https://pact-broker.example.com',
    pactFilesOrDirs: ['./pacts/'],
    consumerVersion: process.env.GIT_COMMIT,
    branch: process.env.GIT_BRANCH,
    tags: ['prod', 'staging']
  }).publish();
};

Verifying Contracts

// CI/CD pipeline - Provider
const verify = async () => {
  const verifier = new Verifier({
    provider: 'user-api',
    providerBaseUrl: 'http://localhost:8080',
    pactBrokerUrl: 'https://pact-broker.example.com',
    providerVersions: [process.env.GIT_COMMIT],
    publishVerificationResult: true,
    providerVersionBranch: process.env.GIT_BRANCH
  });

  const output = await verifier.verifyProvider();
  console.log('Pact verification completed:', output);
};

Can-I-Deploy Checks

# Check if provider can be deployed
pact-broker can-i-deploy \
  --pacticipant user-api \
  --version $VERSION \
  --to-environment production

# Check if consumer can be deployed
pact-broker can-i-deploy \
  --pacticipant web-app \
  --version $VERSION \
  --to-environment staging

Best Practices

1. Contract Design

Principle: Contracts Should Be Consumer-Driven

Consumers should define what they need, not what providers offer. This prevents over-engineering and unnecessary coupling.

// Good: Consumer defines exactly what it needs
{
  "name": pact.like('John'),  // Only the fields consumer uses
  "email": pact.like('john@example.com')
}

// Bad: Consumer expects everything provider offers
{
  "name": pact.like('John'),
  "email": pact.like('john@example.com'),
  "address": pact.like('123 Main St'),      // Consumer doesn't use this
  "phone": pact.like('555-1234'),           // Consumer doesn't use this
  "metadata": pact.somethingLike({})        // Consumer doesn't use this
}

2. Versioning Strategy

  • Semantic Versioning: Use semantic versions for breaking changes
  • Tags: Use environment tags (prod, staging, dev)
  • Branches: Track feature branch deployments

3. State Management

  • Idempotent: State setup should be repeatable
  • Isolated: Each test should have clean state
  • Minimal: Only setup necessary data

4. Test Organization

// Organize tests by feature/state
describe('User API Contracts', () => {
  describe('when user exists', () => {
    test('returns user data');
    test('updates user successfully');
    test('deletes user');
  });

  describe('when user does not exist', () => {
    test('returns 404');
    test('cannot update');
    test('cannot delete');
  });
});

Common Pitfalls & Solutions

1. Over-Specification

Problem: Contracts contain too much detail, making them brittle.

// Bad: Too specific
{
  "createdAt": "2023-01-01T12:00:00.123Z"  // Exact timestamp
}

// Good: Flexible matching
{
  "createdAt": pact.term({
    generate: "2023-01-01T12:00:00.123Z",
    matcher: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
  })
}

2. Missing State Setup

Problem: Provider verification fails due to missing test data.

Solution: Always implement proper state handlers.

3. Contract Drift

Problem: Consumer and provider contracts diverge over time.

Solution: Regular CI/CD checks and can-i-deploy verification.

4. Test Environment Issues

Problem: Tests fail due to environment differences.

Solution: Use Docker containers for consistent test environments.


Tools & Ecosystem

Pact Language Support

  • JavaScript/TypeScript: @pact-foundation/pact
  • Java: pact-jvm
  • Python: pact-python
  • Ruby: pact-ruby
  • Go: pact-go
  • .NET: PactNet

Broker Options

  • Pact Cloud: Hosted service by Pact.io
  • Pact Broker OSS: Self-hosted Ruby application
  • Docker: pactfoundation/pact-broker

CI/CD Integration

# GitHub Actions example
name: Contract Tests
on: [push, pull_request]

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
      - run: npm install
      - run: npm run test:pact
      - run: npm run publish:pact

  provider-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: docker-compose up -d
      - run: npm install
      - run: npm run verify:pact

Contract Testing ≠ Full Integration Testing

Contract testing ensures API compatibility but doesn't test business logic across services. Combine with limited E2E tests for critical user flows.