Steven's Knowledge

Contract Testing

Catching integration bugs between services without spinning up everyone's stack — consumer-driven contracts, schema-first contracts, and where each fits

Contract Testing

When two services talk to each other, the way to catch "you broke my client" without running both services together is contract testing. A contract is a written, executable agreement: "the producer says it will send messages of this shape; the consumer says it expects messages of this shape." Tests on both sides verify their end of the contract — and a CI step verifies they still agree.

Without contract tests, the only way to be sure two services still work together is to run them together — usually in a slow, brittle, hard-to-set-up E2E. Contract tests give you most of the integration confidence with none of the deployment cost.

The framing: a contract is a unit test for an integration point. The producer's test proves it can produce the contracted output; the consumer's test proves it can handle the contracted input. If both pass, they integrate — without ever being in the same process.

When Contract Tests Earn Their Place

Contract testing pays off when:

  • Multiple services talk to each other (microservices, frontend-backend, server-mobile).
  • Independent deployment is a goal. You want to ship the consumer without waiting on the producer, and vice versa.
  • The consumer-producer team boundary is wider than one team. The producer can't know every consumer's expectations from reading their own code.
  • Real integration is expensive. You'd otherwise spin up half your infrastructure to verify a JSON field still exists.

Contract testing is overkill when:

  • One process owns both sides (then it's just unit + integration testing).
  • A single team owns both services and ships them together every time.
  • The producer is a third party you can't run tests against (then you need stubs + monitoring, not contracts).

Two Models

There's no single "contract testing." Two distinct schools, with different strengths:

Consumer-Driven Contracts (CDC) — Pact

The consumer writes a test that records what it expects from the producer. The recording becomes a contract artifact. The producer runs the contract against itself in CI and fails if its actual responses don't match.

// In the consumer's test suite
await provider.given('a user exists with id 123')
  .uponReceiving('a request for user 123')
  .withRequest({ method: 'GET', path: '/users/123' })
  .willRespondWith({
    status: 200,
    body: { id: 123, name: like('Alice'), email: like('alice@example.com') }
  });

// Test runs against a mock provider that enforces the contract
const user = await client.getUser(123);
expect(user.name).toBe('Alice');

The recording gets uploaded (Pact Broker, Pactflow, GitHub releases). The producer's CI pulls the latest contract and replays it against the real service.

Strengths:

  • Producer knows exactly what consumers depend on.
  • Removing a "deprecated" field is safe if no contract uses it.
  • A new consumer with new requirements is visible to the producer immediately.

Weaknesses:

  • High setup cost (Pact Broker or equivalent).
  • The contract represents only what consumers have explicitly tested. New endpoints exist with zero contract.
  • Asymmetric: the producer must support every consumer's expectations.

Schema-First Contracts — OpenAPI / Protobuf / GraphQL

The contract is a schema, written first, that both sides generate from or validate against.

  • Producer: validates that its responses match the schema in tests, and serves the schema as documentation.
  • Consumer: generates a typed client from the schema, or validates incoming responses against it.
# OpenAPI schema
paths:
  /users/{id}:
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

components:
  schemas:
    User:
      type: object
      required: [id, name]
      properties:
        id: { type: integer }
        name: { type: string }
        email: { type: string }

Strengths:

  • Single source of truth, readable as documentation.
  • Tooling exists in every language (codegen, validators, mock servers).
  • Breaking changes are visible as schema diffs.

Weaknesses:

  • Says nothing about which consumers depend on which fields. Removing a field is a guess.
  • Easy to evolve the schema in a way that's technically valid but breaks consumers (renaming optional → required, narrowing an enum).
  • Doesn't enforce behavior, only shape.

Which to Pick

SituationUse
Internal microservices, multiple teamsCDC (Pact)
Public API with many external consumersSchema-first (OpenAPI)
gRPC servicesProtobuf schemas (schema-first by default)
GraphQLGraphQL schema (schema-first by default)
Frontend-backend within one teamSchema-first or shared types
Event-driven (Kafka, Pub/Sub)Schema-first (Avro, JSON Schema) + consumer-driven for high-coordination events

Many teams use both: a schema-first contract for shape, plus consumer-driven contracts for "this specific consumer depends on these specific paths/fields working." They aren't mutually exclusive.

What Contract Tests Don't Test

A contract verifies shape and protocol, not full behavior. Things contracts don't catch:

  • Wrong but valid responses. A User with name: "" matches the contract. Whether that's right behavior is a business rule the contract can't express.
  • Performance. The contract says nothing about latency.
  • Authentication policies. Whether the right user can read this user. Contract tests typically use a fixed test identity.
  • Side effects. A POST /orders that returns 201 satisfies the contract whether or not it actually placed the order. The consumer can't see side effects through the contract layer.
  • End-to-end flows. Logging in, placing an order, getting a confirmation — that's E2E. Contracts test the wire, not the journey.

The complement: contract tests reduce the number of integration tests you need, not the kinds. You still need unit tests for logic, integration tests for behavior with real dependencies, and a thin layer of E2E for cross-system journeys.

Contract Versioning

The hardest part of contract testing in practice is versioning. The producer changes; the consumer hasn't redeployed yet. Now what?

Three rules:

  1. Backwards-compatible changes by default. Add fields; don't remove. Add enum values cautiously; don't repurpose. Don't tighten validation on an existing field.
  2. Track what's deployed where. The Pact Broker tracks which contract versions are in prod, staging, etc. Without this, "is it safe to deprecate this field" has no answer.
  3. Run can-i-deploy checks. Before deploying a new producer version, check that it still satisfies the contracts in production. Pact has can-i-deploy; schema-first systems need equivalent automation.

What kills contract testing in practice: a producer makes a breaking change, ships, and the consumer breaks in prod. The contract caught it in CI, but the team merged anyway "because it's the consumer's job to update." Contract tests work only when the team treats failing contract tests like failing build steps.

Tooling Landscape

ToolModelBest for
PactCDCInternal microservices
PactflowHosted Pact BrokerPact at scale
OpenAPI + SchemathesisSchema-first + property-basedREST APIs
Spring Cloud ContractHybridJVM-heavy stacks
BufProtobuf-basedgRPC, schema linting
GraphQL InspectorGraphQL schema diffsGraphQL APIs
AsyncAPISchema-first for eventsKafka, AMQP, MQTT
Postman / InsomniaAd hocManual API verification, not real contract testing

Don't conflate API documentation tools with contract testing. A swagger.json file isn't a contract until something in CI verifies the real service against it.

Where Contract Tests Run

Different from unit and E2E:

  • In the consumer's CI: the test that produced the contract runs in the consumer's PR check. Failures here mean the consumer's code doesn't match what it claims to expect.
  • In the producer's CI: the contract verification runs on every change to the producer. Failures mean the producer broke something a consumer depends on.
  • In a contract broker: the contract artifacts live here. The broker tracks which versions match which.
  • In a can-i-deploy gate: before deploying the producer, the gate checks that all current consumer contracts still pass.

This last gate is the load-bearing part. Without it, a producer can ship a breaking change and learn about it from the consumer's pager.

Common Failure Modes

Contract drift

The consumer writes contracts during development, then the consumer's tests evolve away from them. The producer keeps satisfying old contracts the consumer no longer actually depends on. Effort gets spent supporting nobody.

Fix: enforce contract regeneration as part of CI. Stale contracts fail.

Producer ignores the contract

The producer's team views contract verification as the consumer team's problem. PRs ship that break contracts; consumer teams scramble. Cultural failure, no technical fix.

Schema-first with no runtime validation

An OpenAPI schema exists; the service generates types from it. But the service doesn't actually validate at runtime that its responses match. A new field gets misnamed; types still compile (because the response is technically valid JSON); consumer breaks in production.

Fix: validate responses against the schema in integration tests, or use a runtime middleware that asserts conformance in staging.

Treating contracts as documentation

A team writes an OpenAPI schema, publishes it, and never tests against it. The schema drifts from the implementation within weeks. Now you have a misleading doc and no contract.

Fix: any contract not executed in CI is documentation, not a contract.

"We have contract tests, we don't need integration tests"

Contract tests verify shape, not behavior. A producer that returns valid-shaped garbage still passes contract tests. You still need integration tests for behavior on the producer side.

Pre-commit Checklist

Before declaring contract testing healthy:

  • Is there a contract for every producer-consumer pair the team owns?
  • Does the producer's CI fail when it breaks a consumer's contract?
  • Is there a can-i-deploy step before producer deploys?
  • Are contracts versioned and stored somewhere durable, not just in CI artifacts?
  • When a contract fails, does the failure message tell both teams what to do?
  • Are the contracts asserting the protocol, not duplicating business-logic assertions?

If the team has Pact installed but no can-i-deploy gate, the contracts are theater — they catch nothing the moment merges happen in the wrong order.

On this page