DevOps ·

Contract Testing for Microservices: Consumer-Driven Contracts, Pact, and Breaking Change Detection in CI/CD

How consumer-driven contract testing with Pact reduces integration test fragility in microservice architectures, with TypeScript examples and a CI/CD deployment gate pattern.

Contract Testing for Microservices: Consumer-Driven Contracts, Pact, and Breaking Change Detection in CI/CD

Integration tests between microservices are expensive. A test suite that spins up five services, a message broker, and two databases takes minutes to run and breaks for reasons unrelated to the code you changed. The provider API returned a field in a different format. The test environment network had a blip. The seeded data drifted. You fix the symptom, not the cause, and move on.

Contract testing is a different approach. Instead of testing services together, you test each service against a precise specification of what the other expects. The consumer defines what it needs. The provider verifies it delivers that. No shared environment. No network calls between services in your test suite. Failures are unambiguous.

This article covers the full picture: why the integration testing model breaks down, how consumer-driven contracts work conceptually, a practical Pact implementation in TypeScript for both sides, the Pact Broker for contract sharing, CI/CD integration with breaking change gates, bi-directional contracts, and where this approach stops making sense.

Why Integration Tests Break Down at Scale

The standard argument for integration tests is that they catch real integration bugs. That is true. The problem is what they also do.

A test that exercises two services together can fail because of a bug in service A, a bug in service B, a configuration difference between the test environment and production, a timing issue in test setup, or infrastructure noise. When the test fails at 2am in CI, you do not know which of those five categories applies. You start investigating.

At two services, this is manageable. At ten, the combinatorial surface is large enough that you are running a distributed system in CI just to get a green build. The environment becomes a maintenance project. The slow feedback loop means developers stop running these tests locally. They become a CI-only check that everyone assumes is a flake until it runs twice.

The deeper problem: integration tests verify behavior at a point in time, but they do not encode what each service actually depends on. If a provider renames a field that no consumer uses, the integration tests catch it anyway and break. If a provider renames a field that one consumer uses in a production code path that no test exercises, the integration tests miss it entirely.

Contract testing inverts this. Consumers declare their dependencies explicitly. Providers verify against those declarations. The test is about the contract, not the combined behavior.

Consumer-Driven Contracts: The Concept

A consumer-driven contract starts with the consumer service. The consumer writes a test that says: “When I call endpoint X with parameters Y, I expect a response that includes fields A and B of types string and number.” That expectation becomes a contract artifact.

The provider then runs a separate test that says: “Given a request matching the consumer’s description, does my actual implementation return something that satisfies the contract?” The provider does not need to know anything about the consumer’s business logic, only that the fields it produces match the shapes it promised.

This is fundamentally different from the provider publishing an OpenAPI spec and hoping consumers read it. The contract is generated by the consumer, verified by the provider, and stored somewhere both sides can access.

The term “consumer-driven” is the key distinction. Providers do not define the contract unilaterally. Consumers do. That matters because providers often have many consumers with different needs. A contract from each consumer tells the provider exactly which fields and behaviors are load-bearing.

Pact: Implementation in TypeScript

Pact is the most widely used consumer-driven contract testing framework. It has implementations for most languages. The TypeScript client is @pact-foundation/pact.

Consumer Side

The consumer writes a test against a Pact mock server. The mock server records the interactions and writes a pact file (JSON) to disk.

import { PactV3, MatchersV3 } from "@pact-foundation/pact";
import { OrderService } from "../src/order-service";
import path from "path";

const { like, string, number, eachLike } = MatchersV3;

const provider = new PactV3({
  consumer: "order-service",
  provider: "inventory-service",
  dir: path.resolve(process.cwd(), "pacts"),
});

describe("OrderService consuming InventoryService", () => {
  it("returns available stock for a product", async () => {
    await provider
      .given("product SKU-123 exists with 50 units in stock")
      .uponReceiving("a request for SKU-123 stock level")
      .withRequest({
        method: "GET",
        path: "/inventory/SKU-123",
        headers: { Accept: "application/json" },
      })
      .willRespondWith({
        status: 200,
        headers: { "Content-Type": "application/json" },
        body: {
          sku: string("SKU-123"),
          available: number(50),
          reserved: number(0),
          warehouse: like({ id: string("WH-01"), region: string("us-east") }),
        },
      })
      .executeTest(async (mockServer) => {
        const service = new OrderService(mockServer.url);
        const stock = await service.getStockLevel("SKU-123");

        expect(stock.sku).toBe("SKU-123");
        expect(stock.available).toBeGreaterThan(0);
      });
  });
});

A few things worth noting in this setup. The like() matcher says “this shape, with compatible types” rather than “this exact value.” The string() and number() matchers assert types. Using literal values without matchers creates fragile contracts that break whenever test data changes.

After this test runs, Pact writes a JSON pact file to the pacts/ directory containing the recorded interaction. That file is what gets shared with the provider.

Provider Side

The provider side uses @pact-foundation/pact with a different entrypoint. The Pact framework replays the interactions from the pact file against your actual running provider and checks the responses.

import { Verifier } from "@pact-foundation/pact";
import path from "path";
import { startServer, stopServer } from "./test-server";

describe("InventoryService provider contract verification", () => {
  let serverUrl: string;

  beforeAll(async () => {
    serverUrl = await startServer();
  });

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

  it("satisfies the order-service consumer contract", async () => {
    const opts = {
      provider: "inventory-service",
      providerBaseUrl: serverUrl,
      pactUrls: [
        path.resolve(
          process.cwd(),
          "pacts/order-service-inventory-service.json"
        ),
      ],
      stateHandlers: {
        "product SKU-123 exists with 50 units in stock": async () => {
          await seedProduct({
            sku: "SKU-123",
            available: 50,
            reserved: 0,
            warehouse: { id: "WH-01", region: "us-east" },
          });
        },
      },
    };

    return new Verifier(opts).verifyProvider();
  });
});

The stateHandlers map is important. Consumer tests declare provider states (the given() calls). The provider must implement each state handler to seed the right data before each interaction replays. Without this, you are testing against whatever random data happens to be in your test database.

State handlers keep the provider test self-contained. They also force you to be explicit about what preconditions each interaction requires, which doubles as documentation.

The Pact Broker

Running pact tests locally is straightforward. The coordination problem comes in CI: the consumer generates a pact file, the provider needs to verify it. If you commit pact files to the consumer repository, providers need to know where to fetch them and when new versions appear.

The Pact Broker solves this. It is a central store for pact files with a versioning model that understands which consumer versions are compatible with which provider versions.

Consumers publish their pact after generating it:

npx pact-broker publish ./pacts \
  --broker-base-url https://your-pact-broker.example.com \
  --consumer-app-version $(git rev-parse HEAD) \
  --branch $(git branch --show-current) \
  --auto-detect-version-properties

Providers verify against the broker instead of a local file path:

const opts = {
  provider: "inventory-service",
  providerBaseUrl: serverUrl,
  pactBrokerUrl: "https://your-pact-broker.example.com",
  publishVerificationResult: true,
  providerVersion: process.env.GIT_SHA,
  providerVersionBranch: process.env.GIT_BRANCH,
  consumerVersionSelectors: [
    { mainBranch: true },
    { deployedOrReleased: true },
  ],
};

The consumerVersionSelectors field controls which consumer pacts the provider verifies against. mainBranch: true picks up the latest from the consumer’s main branch. deployedOrReleased: true picks up consumer versions that are currently deployed to any environment. This prevents a provider from breaking consumers that are live without breaking consumers that are still in development.

Breaking Change Detection as a Deployment Gate

The most valuable thing the Pact Broker provides is the can-i-deploy command. It queries the broker’s compatibility matrix and answers: “Is version X of this service compatible with whatever is currently deployed in environment Y?”

# GitHub Actions example
- name: Check can-i-deploy consumer
  run: |
    npx pact-broker can-i-deploy \
      --pacticipant order-service \
      --version ${{ github.sha }} \
      --to-environment production \
      --broker-base-url ${{ secrets.PACT_BROKER_URL }}

- name: Deploy to production
  if: success()
  run: ./deploy.sh production

This gate exits non-zero if the verification matrix shows any incompatibility. If the order-service consumer published a pact that the inventory-service provider has not yet verified, deployment is blocked. If the inventory-service provider introduced a breaking change, deployment is blocked.

The practical effect: providers cannot deploy changes that break live consumers without those consumers also being updated and verified. It closes the window between “the provider changed a field” and “the consumer broke in production.”

For this to work, you need to record deployments to the broker so it knows what is live:

npx pact-broker record-deployment \
  --pacticipant inventory-service \
  --version ${{ github.sha }} \
  --environment production \
  --broker-base-url ${{ secrets.PACT_BROKER_URL }}

This is an extra step that teams often skip, then wonder why can-i-deploy is always returning false. The broker cannot reason about deployed versions it does not know about.

Bi-Directional Contract Testing

Classic Pact requires both sides to adopt the framework. For third-party APIs or for teams with existing OpenAPI specs, this is not always practical. Pact v4 introduced bi-directional contract testing (BDCT) as an alternative.

In BDCT, the consumer publishes a pact as before. The provider, instead of running a Pact verifier, publishes an OpenAPI spec to the broker. PactFlow (the commercial Pact Broker) then cross-checks the consumer’s expectations against the OpenAPI spec without the provider running any contract-specific tests.

This is a different tradeoff. You get contract compatibility checking without requiring providers to adopt Pact, but you are trusting that the OpenAPI spec accurately represents the actual provider behavior. If the spec is stale or the provider drifts from it, the contract check passes but production breaks.

BDCT is useful when you are a consumer of an external service that publishes an OpenAPI spec, or when you want to adopt contract testing incrementally by using existing specs as a starting point before moving to full verification. It is not a permanent destination for critical internal services.

Contract Testing vs Integration Testing vs E2E Testing

These approaches sit at different points on the same spectrum and catch different failure classes.

ApproachWhat it catchesWhat it missesFeedback speedEnvironment cost
Contract testsSchema changes, field removal, type changesBehavioral bugs, business logic errorsFast (seconds)None (no real network calls)
Integration testsBehavioral integration bugs, auth, middlewareSchema drift between unexercised pathsMedium (minutes)High (shared test env)
E2E testsFull user journey regressionsRoot cause (system too large to diagnose)Slow (10-30+ min)Very high (full stack)

Contract tests are not a replacement for integration tests. They eliminate a large class of integration test failures (schema incompatibility) so your integration tests can focus on behavioral bugs rather than “did the field name change.” In practice, teams that adopt contract testing find they need fewer integration tests because the contracts eliminate the most common source of false failures.

E2E tests remain useful for verifying critical user journeys, but they should be narrow and few. The pyramid still holds: many unit tests, some integration tests, fewer E2E tests. Contract tests slot in between unit and integration.

When Contract Testing Is Overkill

Not every service boundary needs contract tests.

If you own both the consumer and the provider and they are deployed as a unit, contract tests add process without adding safety. A breaking change in the provider would be caught by unit or integration tests before it could diverge.

If your API has one consumer and you change them together, the contract is implicit in your code. The overhead of the Pact setup is not justified.

If your services communicate via events rather than HTTP, Pact supports message contracts, but the setup is more involved and the ROI is lower unless your event schema has caused production incidents.

Contract testing pays off most when services are owned by different teams or deploy independently, when an API has multiple consumers with different needs, and when you have experienced breaking changes that the integration test suite caught too late or missed entirely.

The Pact Broker itself is infrastructure you need to run and maintain. For a team of three shipping a monorepo, this is overhead that buys nothing. For an organization with ten teams and fifty services deploying independently, it is a coordination tool that prevents the kind of “who broke what” investigation that burns hours.

Production Considerations

Versioning pacts with branches and tags. Consumer pacts should be associated with git branches so the provider knows which consumer branch generated which pact. Verifying against main pacts in CI and deployedOrReleased pacts as a deployment gate is a sensible split.

State handler maintenance. State handlers are code. They drift. Add them to the same review process as the tests themselves. A state handler that seeds incorrect data produces a contract test that passes and lies.

Pact file conflicts. If multiple developers on the consumer team run tests locally and push pact files, you get merge conflicts in JSON. The standard solution is to only publish pacts from CI, not from local runs. Configure your CI to publish and your local test run to only write to disk.

Provider test isolation. Provider contract verification should run against a real provider process but with controlled database state (via state handlers) and no external service dependencies. Mock any downstream services the provider calls, the same as any integration test. Otherwise you reintroduce environment fragility through the back door.

Wiring the broker into your deployment pipeline. The can-i-deploy check is only useful if it blocks deployment when it fails. If it is a warning step that engineers override, it provides no safety. Treat it like a failing unit test: fix it or do not deploy.

Contract testing requires discipline to set up, but once the pattern is established, it does what integration tests promise but rarely deliver: fast, specific, unambiguous feedback about whether two services are compatible. The failures point at the contract violation, not at the environment.

The time you spend maintaining pact files and state handlers is less than the time you spend investigating environment-related integration test failures. That is the only math that matters when deciding whether to adopt it.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.