DEV Community

ThankGod Chibugwum Obobo
ThankGod Chibugwum Obobo

Posted on • Originally published at actocodes.hashnode.dev

Contract Testing for Microservices: How to Ensure Reliability in Distributed Systems

In a microservices architecture, services are independently developed, deployed, and scaled. But independence comes with a hidden cost: integration risk. When the orders-service calls the users-service, how do you know the response shape hasn't changed? When the payments-service upgrades its API, how do you catch breaking changes before they reach production?

Unit tests won't catch this. E2E tests are too slow and too brittle to run on every deployment. The answer is contract testing, a lightweight, fast, and precise approach to verifying that services can communicate with each other correctly, without the overhead of a shared test environment.

This guide covers what contract testing is, how consumer-driven contract testing with Pact works, and how to integrate it into your microservices CI/CD pipeline.

What Is Contract Testing?

A contract is a formal agreement between two services about what requests and responses look like. Contract testing verifies that both sides of a service interaction honor that agreement independently, without needing both services running simultaneously.

There are two roles in every contract:

  • Consumer: the service that makes a request and depends on the response (e.g., orders-service calling users-service)
  • Provider: the service that receives the request and returns a response (e.g., users-service)

In consumer-driven contract testing, the consumer defines the contract, specifying exactly what data it needs from the provider. The provider then verifies it can satisfy that contract. If the provider changes its API in a way that breaks the consumer's expectations, the contract test fails.

This flips the traditional integration testing model: instead of testing against a real running provider, the consumer tests against a mock, generates a contract file, and the provider verifies against that file independently.

Why Not Just Use Integration or E2E Tests?

Testing Approach Speed Isolation Catches Breaking Changes Environment Required
Unit tests Fast Full No None
Contract tests Fast Full Yes None
Integration tests Slow Partial Yes Both services
E2E tests Slow None Yes Full stack

Contract tests occupy a unique position: they're as fast and isolated as unit tests, but they verify cross-service compatibility as accurately as integration tests, without requiring both services to run simultaneously.

Introducing Pact

Pact is the most widely adopted consumer-driven contract testing framework. It supports multiple languages (JavaScript/TypeScript, Java, Go, Python, .NET) and provides:

  • A consumer DSL for defining contracts as test code
  • A mock provider that consumers test against
  • A Pact file (JSON) that captures the contract
  • Provider verification tooling that replays the contract against the real provider
  • Pact Broker a hosted registry for sharing and versioning contracts between teams

For this guide, we'll use PactJS with TypeScript in a NestJS microservices context.

Step 1 - Install Pact Dependencies

In your consumer service:

npm install --save-dev @pact-foundation/pact
Enter fullscreen mode Exit fullscreen mode

In your provider service:

npm install --save-dev @pact-foundation/pact
Enter fullscreen mode Exit fullscreen mode

Step 2 - Write the Consumer Contract Test

The consumer test defines what it expects from the provider and generates a Pact file. Here, the orders-service (consumer) defines its contract with the users-service (provider):

// orders-service/src/users/users.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UsersClient } from './users.client';
import path from 'path';

const { like, string, integer } = MatchersV3;

const provider = new PactV3({
  consumer: 'orders-service',
  provider: 'users-service',
  dir: path.resolve(__dirname, '../../pacts'), // where the Pact file is written
  port: 4000,
});

describe('UsersService Contract', () => {
  describe('GET /users/:id', () => {
    it('returns a user when given a valid ID', async () => {
      await provider
        .given('a user with ID usr_001 exists')
        .uponReceiving('a request for user usr_001')
        .withRequest({
          method: 'GET',
          path: '/users/usr_001',
          headers: { Accept: 'application/json' },
        })
        .willRespondWith({
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: {
            id: string('usr_001'),
            email: string('user@example.com'),
            name: string('Jane Doe'),
            role: string('customer'),
            createdAt: string('2025-01-01T00:00:00.000Z'),
          },
        })
        .executeTest(async (mockServer) => {
          const client = new UsersClient(mockServer.url);
          const user = await client.getUser('usr_001');

          expect(user.id).toBe('usr_001');
          expect(user.email).toBeDefined();
          expect(user.name).toBeDefined();
        });
    });

    it('returns 404 when user does not exist', async () => {
      await provider
        .given('no user with ID usr_999 exists')
        .uponReceiving('a request for non-existent user usr_999')
        .withRequest({
          method: 'GET',
          path: '/users/usr_999',
          headers: { Accept: 'application/json' },
        })
        .willRespondWith({
          status: 404,
          body: {
            statusCode: integer(404),
            message: string('User not found'),
          },
        })
        .executeTest(async (mockServer) => {
          const client = new UsersClient(mockServer.url);
          await expect(client.getUser('usr_999')).rejects.toThrow('404');
        });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • Use matchers, not exact values. string('user@example.com') means "any string", not that exact email. This prevents brittle tests that break on dynamic data.
  • Cover failure scenarios. The 404 test is as important as the happy path, the consumer must know how the provider signals failure.
  • Use given() for provider states. These labels tell the provider what data to set up before verifying the contract.

Running this test generates a file at pacts/orders-service-users-service.json, the contract artifact.

Step 3 - Publish the Contract to Pact Broker

The Pact Broker is a central registry where contracts are published and versioned. PactFlow offers a hosted version; self-hosting is also available.

Publish the generated Pact file after the consumer tests pass:

// pact.publish.ts
import { Publisher } from '@pact-foundation/pact';
import path from 'path';

const publisher = new Publisher({
  pactBrokerUrl: process.env.PACT_BROKER_URL!,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN!,
  pactFilesOrDirs: [path.resolve(__dirname, './pacts')],
  consumerVersion: process.env.GIT_COMMIT!, // tag contracts by Git SHA
  tags: [process.env.GIT_BRANCH!],
});

publisher.publishPacts().then(() => {
  console.log('Pact contracts published successfully');
});
Enter fullscreen mode Exit fullscreen mode

Tagging contracts by Git commit SHA and branch name is essential, it lets the broker track which version of a consumer is compatible with which version of a provider.

Step 4 - Verify the Contract on the Provider

The provider fetches the contract from the Pact Broker and replays each interaction against the real running service. In the users-service:

// users-service/src/pact/pact.verify.spec.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';

describe('Pact Provider Verification - users-service', () => {
  it('validates the expectations of orders-service', async () => {
    const verifier = new Verifier({
      provider: 'users-service',
      providerBaseUrl: 'http://localhost:3001', // real provider running locally or in CI
      pactBrokerUrl: process.env.PACT_BROKER_URL!,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN!,
      publishVerificationResult: true,
      providerVersion: process.env.GIT_COMMIT!,

      // Provider state handlers - set up test data for each 'given()' state
      stateHandlers: {
        'a user with ID usr_001 exists': async () => {
          await seedTestUser({ id: 'usr_001', email: 'user@example.com', name: 'Jane Doe' });
        },
        'no user with ID usr_999 exists': async () => {
          await ensureUserAbsent('usr_999');
        },
      },
    });

    await verifier.verifyProvider();
  });
});
Enter fullscreen mode Exit fullscreen mode

The stateHandlers are the bridge between the consumer's given() labels and the provider's test data setup. Each handler runs before the corresponding interaction is replayed, ensuring the provider is in the right state to respond correctly.

Step 5 - Can I Deploy? Preventing Breaking Releases

The most powerful feature of the Pact Broker is can-i-deploy, a CLI command that checks whether a specific version of a service is compatible with the versions already in production before deployment:

npx pact-broker can-i-deploy \
  --pacticipant users-service \
  --version $GIT_COMMIT \
  --to-environment production \
  --broker-base-url $PACT_BROKER_URL \
  --broker-token $PACT_BROKER_TOKEN
Enter fullscreen mode Exit fullscreen mode

If any consumer contract is not verified against the version being deployed, can-i-deploy returns a non-zero exit code, blocking the deployment. This is the contract testing equivalent of a quality gate.

Step 6 - CI/CD Integration

Wire everything together in GitHub Actions, running consumer tests, publishing contracts, verifying on the provider, and gating deployments:

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

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  consumer:
    name: Consumer Contract Tests (orders-service)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run test:pact          # runs Pact consumer tests
      - run: npm run pact:publish       # publishes contracts to broker
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GIT_COMMIT: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}

  provider:
    name: Provider Verification (users-service)
    runs-on: ubuntu-latest
    needs: consumer
    steps:
      - uses: actions/checkout@v4
        with: { repository: 'your-org/users-service' }
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run start:test &       # start provider in test mode
      - run: npm run test:pact:verify   # verify contracts from broker
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GIT_COMMIT: ${{ github.sha }}

  can-i-deploy:
    name: Can I Deploy?
    runs-on: ubuntu-latest
    needs: provider
    steps:
      - run: |
          npx pact-broker can-i-deploy \
            --pacticipant users-service \
            --version ${{ github.sha }} \
            --to-environment production
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid

Testing too much in the contract: A contract should only capture what the consumer actually uses, not every field the provider returns. If the consumer only needs id, email, and name, don't include createdAt or role in the contract. Overly broad contracts make the provider unnecessarily constrained.

Skipping provider states: Without proper stateHandlers, provider verification becomes non-deterministic, sometimes passing, sometimes failing depending on what data happens to exist. Every given() label must have a corresponding handler.

Treating contracts as documentation: Contracts are executable tests, not API docs. If they're not running in CI and gating deployments via can-i-deploy, they provide no safety guarantee.

One consumer, one contract assumption: A provider may have many consumers, each with their own contract. The Pact Broker aggregates all of them, the provider must satisfy every consumer's contract simultaneously.

Conclusion

Contract testing fills the most dangerous gap in microservices testing: the space between "my service works in isolation" and "my services work together in production." With Pact, you get fast, isolated, and precise verification of cross-service compatibility, without shared environments, brittle E2E suites, or manual coordination between teams.

Implement consumer contracts for every external service your application depends on, publish them to the Pact Broker, enforce provider verification in CI, and gate every deployment with can-i-deploy. The result is a distributed system where teams ship independently, and confidently.

Building contracts across mixed-language services, Node.js consumers talking to Java or Go providers? Pact's multi-language support handles this natively. Drop a comment with your stack.

Top comments (0)