In a microservices architecture, services talk to each other through APIs. When the user service changes a response field, the order service breaks. When the payment service adds a required field, deployments fail. These integration bugs are painful — and they usually only show up in staging or production.

Contract testing catches these mismatches earlier. Consumer and provider verify the same contract, independently, without spinning up the entire system.

The Problem With Integration Tests

Integration tests require all services to be running. In a system with dozens of microservices, that means:

  • A full environment to spin up
  • Slow test suites (minutes, not seconds)
  • Flaky tests due to network issues and service availability
  • No clear ownership of what broke when

And they still miss edge cases. A provider changing a field name silently breaks consumers that aren’t in the same test suite.

Contract Testing to the Rescue

In contract testing, the consumer defines what it expects from the provider. The provider verifies that it actually delivers that. No full environment needed.

Consumer team defines: "I call /users/123 and expect { id, name, email }"
Provider team verifies: "Does our /users/:id endpoint actually return that shape?"

The contract is the shared artifact. Pact is the most popular tool for this.

Consumer Side: Define the Contract

The consumer writes a test that describes the interaction it expects:

import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserService } from '../services/user-service';

const { like, string } = MatchersV3;

describe('UserService contract', () => {
  const provider = new PactV3({
    consumer: 'OrderService',
    provider: 'UserService',
    dir: './pacts', // Where the contract file is saved
  });

  it('gets a user by id', async () => {
    await provider
      .addInteraction({
        states: [{ description: 'user 123 exists' }],
        uponReceiving: 'a request for user 123',
        withRequest: {
          method: 'GET',
          path: '/users/123',
          headers: { Authorization: like('Bearer token123') },
        },
        willRespondWith: {
          status: 200,
          body: {
            id: string('123'),
            name: string('Alice'),
            email: string('[email protected]'),
          },
        },
      })
      .executeTest(async (mockProvider) => {
        const userService = new UserService(mockProvider.url);
        const user = await userService.getUser('123');

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

When this test runs, Pact:

  1. Starts a mock server that returns exactly what the consumer expects
  2. Records the interaction into a pact file (JSON) in ./pacts/

The pact file is the contract. It gets published to a Pact Broker (or committed to a repo).

Provider Side: Verify the Contract

The provider pulls the pact file and verifies their real implementation against it:

import { PactV3 } from '@pact-foundation/pact';
import { startServer } from '../app';

describe('UserService provider verification', () => {
  let server: Server;

  beforeAll(async () => {
    server = await startServer(3001);
  });

  afterAll(() => server.close());

  it('validates the contract from OrderService', async () => {
    const options = {
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3001',

      // Pull pacts from broker or local file
      pactUrls: ['./pacts/OrderService-UserService.json'],

      // Or from a Pact Broker:
      // pactBrokerUrl: 'https://your-broker.pactflow.io',
      // consumerVersionSelectors: [{ mainBranch: true }],

      stateHandlers: {
        'user 123 exists': async () => {
          // Set up test data
          await db.users.upsert({ id: '123', name: 'Alice', email: '[email protected]' });
        },
      },
    };

    await new PactV3(options).verifyProvider();
  });
});

If the provider’s response doesn’t match the contract — wrong field name, missing field, wrong type — the test fails. The provider team knows before shipping.

The Pact Broker

Publishing pacts to a central broker (like PactFlow or a self-hosted Pact Broker) enables:

  • Multiple consumers sharing the same provider
  • Version tracking (“does provider v2.3 satisfy consumer v1.8?”)
  • The can-i-deploy check in CI
# Consumer publishes pact after tests pass
pact-broker publish ./pacts \
  --consumer-app-version "$GIT_SHA" \
  --broker-base-url https://your-broker.pactflow.io

# Before deploying, check if it's safe
pact-broker can-i-deploy \
  --pacticipant OrderService \
  --version "$GIT_SHA" \
  --to-environment production

This gives you deployment confidence without integration environments.

What Contract Tests Don’t Cover

Contract testing verifies the shape of the API, not the business logic. They check:

  • ✅ Field names and types
  • ✅ HTTP status codes
  • ✅ Required vs optional fields
  • ❌ Whether the backend logic is correct
  • ❌ Performance
  • ❌ Auth edge cases

You still need unit tests for business logic and a few smoke tests in staging.

When to Add Contract Testing

Contract testing pays off when:

  • Multiple teams own different services
  • API changes frequently break consumers
  • You want to reduce reliance on shared staging environments
  • You’re moving toward continuous deployment

It’s overkill for:

  • A monolith with internal module boundaries
  • A single team owning all services
  • Simple, stable APIs

Key Takeaways

  • Contract testing verifies API agreements between consumer and provider independently
  • The consumer defines the contract; the provider verifies it
  • Pact is the standard tool — use PactV3 for modern TypeScript setups
  • A Pact Broker stores pacts and enables can-i-deploy checks
  • Contract tests don’t replace unit or smoke tests — they complement them