Skip to content

When Do You Actually Need Contract Testing? The Limits of API Testing in Microservices

  • by

Mocks Passed, Production Broke: A Familiar Story

Most integration failures in a microservice estate are not coding errors. They are stale assumptions. A consumer team wrote a mock for GET /orders/42 eighteen months ago, the provider team renamed total_amount to amount.gross last sprint, and nothing in either pipeline noticed. The consumer’s API tests are green because they are asserting against a fixture that froze the truth as it existed on the day it was written.

That is the structural problem with mocks: they make your tests fast and independent, and in exchange they stop telling you anything about the other side. This typically surfaces in one of two ways — a 500 in a checkout flow the moment a null lands in a field the consumer treats as required, or, worse, a silent undefined that renders as an empty price on a page and gets discovered by a customer, not a test. Mocking is still the right tool for isolation (mocking and stubbing, in Turkish); the missing piece is a mechanism that keeps the mock honest.

What a Contract Test Actually Verifies

A contract test is not a functional test. It does not care whether your discount calculation is correct or whether the order total matches the sum of line items. It verifies one thing: that the messages exchanged between two services still match what each side expects.

Concretely, it records the fields the consumer actually reads, the HTTP method and path it actually calls, the status codes it actually handles — and then independently replays that expectation against the provider. If the provider still satisfies it, both sides are free to deploy. If it doesn’t, you know before merge which consumer breaks and on which field.

The value is in the word independently. Nobody has to spin up both services. The consumer’s expectation is captured as a file, and the provider’s pipeline verifies against that file. You are protecting the interface, not the business rule.

Consumer-Driven vs. Provider-Driven Contracts: Choose With Intent

Consumer-driven (Pact-style): each consumer writes down what it needs, and the union of those needs becomes the contract. The provider is explicitly free to change anything no consumer reads. This is the correct default inside an internal microservice network, because it gives you a machine-readable answer to “who actually uses this field?” — the question that blocks most refactors.

Provider-driven / schema-first (OpenAPI, JSON Schema, protobuf): the provider publishes its promise and consumers validate against it. This is the correct default for a public API, where you don’t know your consumers and can’t collect their expectations. It’s also cheaper when the number of consumers is large and their needs are near-identical.

Picking the wrong one costs maintenance. Consumer-driven contracts on a public API mean a contract that represents three of your two hundred clients. Schema-only validation inside an internal mesh means you learn that a schema is backward-compatible but not that anyone still depends on the field you just deprecated.

Where Contract Tests Sit in the Test Pyramid

Contract tests fill the gap between unit tests and end-to-end tests. They do not replace integration tests — they reduce how many you need. The question an E2E suite is usually trying to answer, “can these services still talk to each other?”, is answered by a contract test in seconds, with one service running.

Unit tests rise to Contract tests, then Integration tests, then End-to-end tests; Contract links to a scope note.

The practical effect is a shift in where failures are found. Cross-service shape mismatches move down to the contract layer, and the E2E suite is left with what only it can cover: multi-service business journeys. Teams that make this move usually shrink the E2E suite rather than speed it up — fewer tests, each one justified by a user journey instead of by a fear of unknown breakage. If you want the layer definitions first, see test levels (in Turkish).

When You Actually Need It: Five Signals

Contract testing is not free. It adds a broker, a publishing step, provider state setup, and a new failure mode in CI. Five signals tell you the cost is worth paying:

  1. Services live in separate repositories and deploy through separate pipelines. Nothing forces the two sides to be consistent at build time.
  2. A provider has more than two consumers. With one consumer you can coordinate by talking. With five you can’t.
  3. The provider team does not know who its consumers are. This is the strongest signal. If nobody can list the consumers of an endpoint, no one can safely change it.
  4. Integration defects are only visible in staging or production. Meaning: the feedback loop is measured in hours or days, not in a pipeline run.
  5. The E2E suite is rewritten constantly because it’s brittle. Teams in this state usually have E2E tests doing contract work — asserting response shapes through a browser. That’s the most expensive possible place to check a field name. (Related: diagnosing flaky tests, in Turkish.)

Three or more of these, and contract testing pays for itself. One, and you’re buying insurance against a risk you don’t have.

When You Don’t Need It: The Cost of Over-Engineering

A single deployment unit, a monorepo, one team: contract testing is a net loss. Two modules compiled in the same pipeline already have their contract verified by the compiler or the type system, and they cannot be deployed at different versions. Adding Pact there gives you a second, weaker type checker with a broker attached.

The same is true for a service with one or two consumers deployed synchronously. A well-written integration test that calls the real provider on a throwaway environment is cheaper to write, cheaper to read, and catches more (it catches serialization, status codes, and behavior). Contract testing earns its keep when independent deployability is the thing you’re protecting. If your services cannot deploy independently anyway, you’re paying for a property you don’t use.

Generating the Contract on the Consumer Side

On the consumer side you write the expectation against a local mock server that the contract tool controls. When the test runs and passes, the tool writes a machine-readable pact file. The critical discipline: put only the fields the consumer actually reads into the contract. Copying the full JSON body makes the provider’s every additive change a false failure.

// src/orders-client.js
const BASE_URL = process.env.ORDERS_API_URL;

async function fetchOrderSummary(orderId) {
  const response = await fetch(`${BASE_URL}/orders/${orderId}`, {
    headers: { Accept: 'application/json' },
  });

  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`orders-api returned ${response.status}`);

  const body = await response.json();
  return {
    id: body.id,
    status: body.status,
    grossTotal: body.amount.gross,
    currency: body.amount.currency,
  };
}

module.exports = { fetchOrderSummary, BASE_URL };
// test/orders-client.pact.test.js
const path = require('path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, integer, string, regex } = MatchersV3;

const provider = new PactV3({
  consumer: 'checkout-web',
  provider: 'orders-api',
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn',
});

describe('orders-api contract', () => {
  it('returns the fields checkout-web reads for an existing order', async () => {
    provider
      .given('an order 42 exists for a confirmed customer')
      .uponReceiving('a request for order 42')
      .withRequest({
        method: 'GET',
        path: '/orders/42',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: integer(42),
          status: regex('CONFIRMED|CANCELLED|PENDING', 'CONFIRMED'),
          amount: {
            gross: like(149.9),
            currency: string('EUR'),
          },
        },
      });

    await provider.executeTest(async (mockServer) => {
      process.env.ORDERS_API_URL = mockServer.url;
      const { fetchOrderSummary } = require('../src/orders-client');

      const summary = await fetchOrderSummary(42);

      expect(summary).toEqual({
        id: 42,
        status: 'CONFIRMED',
        grossTotal: 149.9,
        currency: 'EUR',
      });
    });
  });
});

Note what is absent: createdAt, lineItems, customer — all real fields on that resource, none of them read by this consumer. And note the matchers. integer(42) says “an integer, example 42”, not “exactly 42”. regex pins the status enum because the consumer branches on it. Exact values only where the consumer’s behavior depends on the exact value.

Verifying on the Provider Side and Setting Up State

The provider replays the published contract in its own pipeline. The real difficulty is not replay — it’s provider state. The contract says “an order 42 exists for a confirmed customer”; the provider must be able to create that world deterministically before each interaction.

Build state through the same fixtures your other tests use, not through raw SQL against a running database. Direct writes drift from the schema, skip invariants the application enforces, and leak between interactions — at which point your contract verification becomes a flaky test with extra steps. The rules from test data management (in Turkish) apply here without modification.

// test/provider-verification.test.js
const { Verifier } = require('@pact-foundation/pact');
const { startServer, stopServer } = require('../src/server');
const { truncateAll, createCustomer, createOrder } = require('./fixtures');

const PORT = 8099;

describe('orders-api verifies consumer contracts', () => {
  let server;

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

  afterAll(async () => {
    await stopServer(server);
  });

  it('satisfies every published expectation', async () => {
    const output = await new Verifier({
      provider: 'orders-api',
      providerBaseUrl: `http://127.0.0.1:${PORT}`,
      providerVersion: process.env.GIT_COMMIT,
      providerVersionBranch: process.env.GIT_BRANCH,
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      consumerVersionSelectors: [
        { mainBranch: true },
        { deployedOrReleased: true },
      ],
      publishVerificationResult: process.env.CI === 'true',
      stateHandlers: {
        'an order 42 exists for a confirmed customer': async () => {
          await truncateAll();
          const customer = await createCustomer({ status: 'CONFIRMED' });
          await createOrder({
            id: 42,
            customerId: customer.id,
            status: 'CONFIRMED',
            grossAmount: 149.9,
            currency: 'EUR',
          });
          return 'order 42 created';
        },
        'no order 42 exists': async () => {
          await truncateAll();
          return 'database empty';
        },
      },
    }).verifyProvider();

    expect(output).toContain('finished');
  });
});

consumerVersionSelectors is the part teams skip and then regret. Without it you verify against whatever pact was uploaded last, including a feature branch nobody merged. mainBranch plus deployedOrReleased means: verify against what is on trunk and what is actually running somewhere.

Making the Contract a Blocking Gate in CI

Until the contract blocks a deployment, it is decoration. The mechanism is a broker plus a deployability check: before the provider deploys, it must have been verified against every consumer version currently in the target environment.

Consumer CI publishes pacts to Pact Broker, Provider CI verifies, then can-i-deploy gates Deploy provider or Pipeline fails.
# .github/workflows/orders-api.yml
name: orders-api

on:
  push:
    branches: [main]

env:
  PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
  PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

jobs:
  verify-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Verify consumer contracts
        env:
          GIT_COMMIT: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}
          PACT_BROKER_URL: ${{ env.PACT_BROKER_BASE_URL }}
        run: npm run test:pact:provider

      - name: Install pact CLI
        run: |
          npm install -g @pact-foundation/pact-cli

      - name: Can I deploy to production?
        run: |
          pact-broker can-i-deploy \
            --pacticipant orders-api \
            --version "${{ github.sha }}" \
            --to-environment production \
            --retry-while-unknown 6 \
            --retry-interval 10

      - name: Deploy
        run: ./scripts/deploy.sh production

      - name: Record deployment
        run: |
          pact-broker record-deployment \
            --pacticipant orders-api \
            --version "${{ github.sha }}" \
            --environment production

record-deployment is what makes can-i-deploy meaningful — the broker can only compare against production consumers if something tells it what is in production. Skip that step and the gate answers “yes” to everything.

What Contract Testing Does Not Cover

Write the boundary down before someone assumes it’s wider than it is. Contract tests do not cover:

  • Performance. A verified response can take nine seconds.
  • Authorization behavior. Whether user A may read order 42 is a business rule, not a message shape.
  • Business rule correctness. amount.gross being a number says nothing about it being the right number.
  • Network and infrastructure faults. Timeouts, retries, TLS, partial failures, circuit breakers.
  • Data consistency across services. Two services agreeing on a format is not two services agreeing on a fact.

Teams that announce “we deleted all our integration tests” learn these gaps in production. Contract testing narrows what integration and E2E tests must cover; it does not empty the categories. Keep the API-level checks that validate behavior rather than shape — see why and how to automate API tests (in Turkish).

Contracts for Asynchronous Messaging

For Kafka or RabbitMQ flows, contract testing is worth more than it is for HTTP — precisely because end-to-end testing there is so expensive. Reproducing a broker, a topic, a consumer group and an ordering guarantee in CI is a significant investment, and the result is slow and flaky.

The async contract is about the message payload and schema evolution. The producer’s test asserts “when I publish an OrderConfirmed event, it has this shape”; the consumer’s test asserts “given a message of this shape, my handler processes it.” Neither test needs a broker.

The distinction that matters is between adding an optional field (backward compatible — existing consumers keep working) and removing or renaming one (breaking). A schema registry catches some of this at the schema level. A contract test catches it earlier and with more information: it tells you which consumer reads the field you’re about to delete, which the registry cannot know.

How to Ship Your First Contract This Week

Don’t roll this out across the estate. Pick the single integration that breaks most often — the one where someone in a channel says “did orders-api change something?” — and do this:

  1. Write the consumer-side contract for one endpoint, covering only the fields that consumer reads.
  2. Publish it to a broker. A hosted PactFlow trial or a self-hosted broker container are both fine for this.
  3. Add the verification step to the provider’s pipeline, non-blocking.
  4. Leave the gate non-blocking for two weeks, on purpose. Let it report.

Then count. If the contract caught breakages during those two weeks, turn on can-i-deploy and move to the next integration. If it caught zero, that integration did not need a contract test — the two teams were already coordinating well enough. Move the experiment somewhere else instead of declaring success and adding maintenance to a pipeline that gained nothing.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.