Skip to main content

API Contract Testing in Microservices: Moving Beyond Mocking

June 2, 2026

</>

In a microservices architecture, applications are split into independent services that communicate over network boundaries using API endpoints. Traditional integration testing relies on mocking these services during local test runs. However, mocks are passive—they never evolve automatically. If the backend team renames a response field, removes a property, or changes a status code, your frontend tests will still pass against the outdated mock. Your application breaks in production.

This is where API Contract Testing is essential.

This post explains the mechanics of Consumer-Driven Contract testing, contrasts it with traditional E2E testing, provides contract test scripts in TypeScript using Pact, and maps out a CI/CD contract verification pipeline.


What is Contract Testing?

Contract testing ensures that two separate systems—the Consumer (who fetches data) and the Provider (who generates data)—comply with a shared schema agreement called the Contract.

Instead of waiting for slow, environment-dependent integration tests, both sides verify their implementations against the contract document in complete isolation:

CONTRACT VERIFICATION PIPELINE:
┌─────────────────┐  Publishes  ┌──────────────────┐
 Consumer Test   ├────────────►│ Contract File    
 (Frontend Pact)               (JSON Schema)    
└─────────────────┘             └────────┬─────────┘
                                         
                                          Shared via Pact Broker
                                         
┌─────────────────┐  Validates  ┌──────────────────┐
 Provider Test   ├────────────►│ Contract Broker  
 (Backend API)                 (PactFlow / OSS) 
└─────────────────┘             └──────────────────┘

If either team attempts to merge code that violates the contract, their tests fail immediately at the pull request build gate—before any code reaches staging.


Comparison: Contract Testing vs. E2E Testing

MetricContract TestingEnd-to-End (E2E) Testing
Feedback LoopFast (seconds, runs locally during compilation)Slow (minutes/hours, requires fully deployed environments)
FlakinessExtremely Low (deterministic, no network calls)High (subject to database sync, delays, and network glitches)
DebuggingPrecise (tells you exactly which schema key fails)Difficult (requires tracing logs across multiple microservices)
MaintenanceLow (contracts update programmatically)High (requires maintaining visual scripts and environment sync)
Coverage ScopeSchema integrity between two servicesFull user journey across all services

Contract testing does not replace E2E testing—it eliminates the class of failures that E2E tests are worst at diagnosing: silent schema drift between teams.


Writing a Consumer Pact Test in TypeScript

On the consumer side, the frontend defines the expected schema for a /users/:id endpoint. The Pact library spins up a local mock server that returns the agreed contract response, so the consumer test runs with zero network dependency:

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

// 1. Initialize Pact Provider Mock Setup
const provider = new PactV3({
  consumer: 'Frontend-User-App',
  provider: 'Backend-User-Service',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('User Profile API Contract', () => {
  it('should receive a valid user profile response', async () => {
    // 2. Define the expected interaction contract
    provider.uponReceiving('a request for user metadata')
      .withRequest({
        method: 'GET',
        path: '/users/101',
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          // Use matchers to validate type and structure rather than exact values
          id: MatchersV3.integer(101),
          name: MatchersV3.string('Sabaoon'),
          email: MatchersV3.email('info@sabaoon.dev'),
          isActive: MatchersV3.boolean(true),
        },
      });

    // 3. Run consumer test against the local mock server
    await provider.executeTest(async (mockServer) => {
      const response = await fetch(`${mockServer.url}/users/101`);
      const user = await response.json();

      expect(response.status).toBe(200);
      expect(user.name).toBeDefined();
      expect(user.isActive).toBe(true);
    });
  });
});

When this test runs, Pact outputs a contract JSON file (Frontend-User-App-Backend-User-Service.json) containing the exact schema constraints. This file is the source of truth shared between teams.


Writing a Provider Verification Test

On the backend (provider) side, the team pulls the published contract from the Pact Broker and verifies their live API implementation against it:

import { Verifier } from '@pact-foundation/pact';
import path from 'path';

describe('Provider Verification: Backend-User-Service', () => {
  it('should satisfy the Frontend-User-App contract', async () => {
    const verifier = new Verifier({
      provider: 'Backend-User-Service',
      providerBaseUrl: 'http://localhost:3001', // Local running backend
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      publishVerificationResult: true,
      providerVersion: process.env.GIT_COMMIT_SHA,
    });

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

If the backend team renames isActive to status, this verification test fails immediately on their PR—before the change reaches production and breaks the frontend.


The CI/CD Integration Pipeline

Once the contract JSON is generated, the flow becomes automated:

  1. Publish: The consumer (frontend) CI pipeline uploads the contract to a central Pact Broker (self-hosted or PactFlow) after every successful build.
  2. Verify: The provider (backend) CI pipeline retrieves the contract and runs verification against its actual API endpoints.
  3. Can I Deploy?: Both pipelines call can-i-deploy via the Pact CLI before promoting to production. This CLI tool checks the Broker to verify that the deployed consumer and provider versions are mutually compatible.
# GitHub Actions: Consumer CI Step
- name: Publish Pact contracts
  run: npx pact-broker publish ./pacts \
    --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
    --broker-token ${{ secrets.PACT_BROKER_TOKEN }} \
    --consumer-app-version ${{ github.sha }}

- name: Can I Deploy?
  run: npx pact-broker can-i-deploy \
    --pacticipant Frontend-User-App \
    --version ${{ github.sha }} \
    --to-environment production

This can-i-deploy gate is the decisive advantage of contract testing over mocking: it gives teams a cryptographic guarantee that their version is compatible before a single byte hits production.


Conclusion

API mocking provides a temporary sense of security, but fails silently when the real provider changes. By implementing consumer-driven contract testing with Pact, you create a shared, versioned, and automatically verified schema agreement between services. The feedback is fast, the failures are precise, and the can-i-deploy gate eliminates the category of integration bugs that only appear in staging or production. For any team operating more than two microservices, contract testing is not optional—it is the engineering discipline that makes independent deployability safe.

Recommended Posts