#TypeScript#DevOps

Contract Testing: Consumer-Driven vs. Provider Verification

Microservice integration tests are expensive because every service must be started. Contract tests verify only that API agreements remain unchanged. This article explains Pact's consumer-driven flow: consumers define expectations and generate contracts, while providers verify them to reveal before merge whether a change breaks a frontend or downstream

Key Points

  • Topic Focus: TypeScript、DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Contract Testing: Consumer-Driven vs. Provider Verification

Let's start with a wall that every microservices team eventually hits.

One day, you change a field in the user service. All local unit tests pass, CI is green, and the change is merged and deployed. Ten minutes later, the order service goes down, the frontend shows a blank screen, and the alert channel erupts. After investigating, you discover that you split user.name into firstName / lastName, while the order service is still reading name and the frontend is still rendering user.name.

The problem is not your code; your code is correct. The problem is that nobody knows who depends on this field. Integration tests should have caught it, but they cannot even run reliably: you must start the user service, order service, frontend, gateway, database, and message queue together, then prepare an interconnected set of test data. This environment is itself a bug that can never be fully fixed.

Contract testing exists to solve this problem. It does not verify whether the business logic is correct. It verifies one thing only: whether the API contract has changed.

1. Why Integration Tests Break Down in Microservices

First, accept one fact: the cost-benefit ratio of end-to-end integration testing is inverted in a microservices architecture.

In the classic testing pyramid, integration tests sit in the middle, verifying whether several modules work when assembled. That is not difficult in a monolith: start one process and connect it to a test database. Microservices turn "modules" into "processes," causing integration testing costs to explode:

  • You must start every related service. Testing an order workflow may require the order, user, inventory, payment, and notification services, plus each service's database, cache, and messaging middleware.
  • You must prepare interconnected test data. A user referenced by an order service test must already exist in the user service, while inventory deductions must align with payment state. The data setup script becomes longer than the test itself.
  • The environment is always drifting. Docker Compose works today; tomorrow, an image tag is upgraded or a service configuration changes, and the environment breaks. Maintaining the integration environment becomes a full-time job.
  • Failures are extremely difficult to diagnose. When an integration test fails, you do not know which service caused it. Was the data prepared incorrectly, did the network time out, or does a service actually contain a bug? Flaky tests proliferate, and the team soon starts ignoring red builds.

Even more damaging is the feedback speed. An integration test suite takes more than ten minutes, so developers do not run it locally. By the time CI reports the result, you have already switched to another task. Integration testing becomes a "pre-deployment ritual" instead of a feedback tool during development.

Integration tests are not useless, but microservices increase their cost by an order of magnitude. You cannot run the entire integration suite for every PR. That leaves the API contract layer completely exposed: nobody can know before merge whether a change will break downstream consumers.

Contract testing fills this gap. It extracts "whether the API contract is being honored" from expensive integration testing and turns it into an inexpensive test that can run on every commit without starting any dependencies.

2. The Core Idea of Contract Testing: Verify the Contract, Not the Logic

Contract testing can be summarized in one sentence: the expectations between a consumer and a provider should be captured in an executable, version-controlled document.

Notice the key limitation: it verifies only the API contract.

What it verifiesWhat it does not verify
Whether request paths, methods, and parameters matchWhether business calculations are correct
Whether response fields are present and have the correct typesWhether database queries are correct
Whether status codes and headers match expectationsWhether requests time out under heavy concurrency
Whether fields were silently removed or renamedComplete business workflows across services

This means contract tests, unit tests, and integration tests are complementary, not substitutes. Unit tests ensure that your logic is correct, contract tests ensure that your API has not drifted, and a small number of critical-path integration tests ensure that the assembled system works.

Why make this separation? Because API contracts and business logic often change at very different rates. Business logic changes every day, but the structure returned by GET /users/:id should not. Once that structure changes, the impact crosses team boundaries, and someone must know immediately. Contract testing provides that immediate signal.

Its value is not in "finding more bugs," but in making the impact of API changes visible before merge, turning "it broke downstream" into "downstream has already verified it."

3. How Pact Works: Consumer-Driven Contracts

Pact is the leading implementation of consumer-driven contract testing (CDC, Consumer-Driven Contracts). Its core design is particularly elegant. Once you understand that design, you understand why it works.

"Consumer-driven" is the key phrase. Traditional API documentation is written by the provider: the backend publishes Swagger documentation, and the frontend follows it. The problem is that providers may not follow their own documentation, while consumers have little leverage to challenge it. Pact reverses the relationship: the consumer defines "what I expect you to return," and the provider proves "I actually return it."

The workflow has two stages that run independently on each side:

text
┌──────────────────────────────────────────────────────────────┐
│  Consumer side (write expectations → generate a contract)    │
│                                                              │
│  1. The consumer writes a test: when I call GET /users/1,    │
│     I expect { id, name }                                    │
│  2. Pact starts a local mock service and intercepts the      │
│     request                                                  │
│  3. The test asserts that the consumer code handles the      │
│     response correctly                                      │
│  4. Test passes → Pact serializes the request and expected   │
│     response into a JSON contract file                       │
└──────────────────────────┬───────────────────────────────────┘
                           │  pact publish (publish to Broker)
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Provider side (fetch contracts → verify)                    │
│                                                              │
│  1. The provider fetches contracts defined by every consumer │
│     from the Broker                                          │
│  2. Pact replays each contract request against the real      │
│     provider service                                         │
│  3. It compares the actual response with the expected one    │
│  4. Every response matches → verification passes; any field  │
│     mismatch → verification fails                            │
└──────────────────────────────────────────────────────────────┘

There is an ingenious detail here: the consumer test is itself the contract-generation process. The consumer does not write a separate document. It writes a real test, uses its own HTTP client to call the mock, receives the expected data from the mock, and asserts that its business logic works. If the consumer's response expectations are wrong, such as expecting a completely unreasonable field, the consumer-side test fails and forces the consumer to correct it. The resulting contract therefore naturally represents what the consumer actually needs, rather than documentation written from assumptions.

Provider-side verification treats the contract as a request fixture against the real service. Nothing needs to be mocked, and no preexisting data is required. Pact asks the provider test to prepare the corresponding data state through Provider States, then replays the request and compares the response. The provider only has to prove, "Given these inputs, I actually return these outputs."

4. In Practice: Defining Expectations on the Consumer Side with Pact

We will use Pact JS. Assume we are building a frontend that consumes a backend user service.

First, install the dependencies:

bash
pnpm add -D @pact-foundation/pact @pact-foundation/pact-node

The core of a consumer test is to use the real API client against the mock service started by Pact, then assert that your code handles the response correctly.

typescript
// src/tests/user-api.pact.test.ts
import path from 'node:path'
import { Pact, MatchersV3 } from '@pact-foundation/pact'
import { getUser } from '../api/user-api' // Your real API client

const { like, eachLike } = MatchersV3

// Create a Pact mock provider
const provider = new Pact({
  consumer: 'web-frontend',
  provider: 'user-service',
  port: 4001,
  dir: path.resolve(__dirname, '../../pacts'),
  log: path.resolve(__dirname, '../logs/pact.log'),
})

describe('User service contract', () => {
  beforeAll(() => provider.setup())
  afterEach(() => provider.verify()) // Key: verify that the real request matches the mock configuration
  afterAll(() => provider.finalize()) // Key: generate the contract file after the tests finish

  describe('GET /users/:id', () => {
    it('gets user details by ID', async () => {
      // Step 1: declare the expectation: when I call this API, I expect this structure
      await provider.addInteraction({
        uponReceiving: 'a request for user details',
        withRequest: {
          method: 'GET',
          path: '/users/123',
          headers: { Accept: 'application/json' },
        },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          // like() means "the structure matters, the value does not": only validate the type
          body: {
            id: like(123),
            name: like('Alice'),
            email: like('[email protected]'),
          },
        },
      })

      // Step 2: use the real API client against the mock service
      // The base URL must point to the Pact mock (localhost:4001)
      const user = await getUser(123, { baseUrl: 'http://localhost:4001' })

      // Step 3: assert that your business code handled the response correctly
      expect(user).toEqual({
        id: 123,
        name: 'Alice',
        email: '[email protected]',
      })
    })
  })
})

Several details in this code are important:

First, the MatchersV3 matchers are the heart of Pact. like(123) does not mean "the value must be 123." It means "this position must contain a numeric value." When Pact generates the contract, it serializes like(123) into a matching rule: {"matchers": [{"match": "type"}]}. During provider verification, returning id: 456 still passes, but returning id: "abc" fails. This ensures that the contract validates the structure, not specific test data, avoiding pointless failures when the provider uses different test data.

Second, provider.verify() runs after every test. It checks whether the consumer actually issued the request declared in withRequest, including the correct path, method, and headers. If the consumer declares "I will send GET /users/123" but the actual code sends GET /user/123 without the s, the test fails here. This ensures that the contract reflects the consumer's actual behavior.

Third, provider.finalize() generates the contract. After all tests finish, Pact aggregates every interaction into a JSON file and writes it to pacts/web-frontend-user-service.json. This file is the "contract." It will later be published to the Broker and fetched by the provider for verification.

The generated contract looks like this in simplified form:

json
{
  "consumer": { "name": "web-frontend" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "a request for user details",
      "request": {
        "method": "GET",
        "path": "/users/123",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": { "id": 123, "name": "Alice", "email": "[email protected]" },
        "matchingRules": {
          "body": {
            "$.id": { "match": "type" },
            "$.name": { "match": "type" },
            "$.email": { "match": "type" }
          }
        }
      }
    }
  ]
}

This JSON is the "API agreement" between the frontend and backend. The consumer says, "I need this," and the provider must prove, "This is what I deliver."

5. In Practice: Verifying the Contract on the Provider Side

Now switch to the provider's perspective, the backend user-service. Its task is simple: obtain the contract, replay its requests against the real service, and compare the responses.

typescript
// src/tests/pact-verification.test.ts
import path from 'node:path'
import { Verifier } from '@pact-foundation/pact'
import { app } from '../app' // Your Express/Fastify/Nest application instance

describe('Pact contract verification', () => {
  const server = app.listen(8081)

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

  it('satisfies every web-frontend contract', () => {
    const opts = {
      provider: 'user-service',
      providerBaseUrl: 'http://localhost:8081',
      // Load the contract locally for this example; production loads it from the Broker
      pactUrls: [path.resolve(__dirname, '../../pacts/web-frontend-user-service.json')],
      // Provider States: tell the provider what data to prepare now
      stateHandlers: {
        'user 123 exists': async () => {
          await db.user.upsert({ id: 123, name: 'Alice', email: '[email protected]' })
          return {} // Cleanup after the test can be performed here
        },
        'user does not exist': async () => {
          await db.user.deleteMany({ id: 999 })
          return {}
        },
      },
    }

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

The most important concept here is Provider States. The consumer declares "a request for user details," but during verification the provider must know: what data must exist in my database to satisfy this contract?

The consumer can declare the state when defining the expectation:

typescript
await provider.addInteraction({
  uponReceiving: 'a request for user details',
  // Declare the provider state on which this expectation depends
  providerState: 'user 123 exists',
  withRequest: { /* ... */ },
  willRespondWith: { /* ... */ },
})

When the provider's stateHandlers receives the string 'user 123 exists', it inserts the corresponding user into the test database. When Pact replays the request, the real service can return that data. The state mechanism means contract verification does not depend on any external environment or preloaded data. The test creates the state itself and cleans it up afterward.

When verification fails, Pact provides a very precise diff:

text
Verifying a pact between web-frontend and user-service
  a request for user details
    has status code 200 (OK)
    has a matching body (FAILED)

Failures:

1) Verifying a pact between web-frontend and user-service Given 'user 123 exists'
   expected response body to contain "email" but was missing
   at $.email

This is contract testing's decisive advantage: it tells you exactly which field failed, at which level, and why. Compared with an integration test where a long call chain makes it unclear what failed, this is a fundamental improvement.

6. Frontend and Backend: Protection in Both Directions

Pact is particularly effective for frontend-backend integration because it can detect breaking changes in both directions.

The backend changes the API while the frontend remains unchanged: the backend renames name to fullName. Provider verification runs the contract, which requires name, but the response now contains fullName, so verification fails. Before merging, the backend learns, "I broke the frontend's expectation." It must either revert the change or coordinate an update with the frontend.

The frontend changes its expectation while the backend remains unchanged: a new frontend requirement needs an avatar field, so the consumer test is updated and generates a new contract. The new contract is published to the Broker, and the backend CI fetches and verifies it. The contract requires avatar, but the backend does not return that field, so verification fails. The backend learns, "The frontend has a new requirement; I need to add this field."

That is the power of being consumer-driven: every frontend expectation is a "requirement," and the backend must prove that it satisfies it. There is no stale API document that nobody updated. The contract is living documentation. If verification fails, the build turns red, and the failure must be addressed.

7. Service-to-Service Scenario: Service A Calls Service B

Service-to-service calls are the primary use case for contract testing. Suppose the order service calls the user service to retrieve user information:

text
order-service (consumer) ──calls──> user-service (provider)

The order service team writes the consumer test:

typescript
// In order-service
const provider = new Pact({
  consumer: 'order-service',
  provider: 'user-service',
  dir: path.resolve(__dirname, '../../pacts'),
})

it('gets the user shipping address when placing an order', async () => {
  await provider.addInteraction({
    uponReceiving: 'a request for the user address while placing an order',
    providerState: 'user 123 has a shipping address',
    withRequest: {
      method: 'GET',
      path: '/users/123',
      query: { fields: 'address' },
    },
    willRespondWith: {
      status: 200,
      body: {
        id: like(123),
        address: like({
          city: like('Shanghai'),
          street: like('1 Nanjing Road'),
        }),
      },
    },
  })

  const order = await createOrder(123, { baseUrl: 'http://localhost:4001' })
  expect(order.shippingAddress.city).toBe('Shanghai')
})

The user service team runs provider verification to ensure that its service still satisfies the order service's expectations.

The key point is that the two teams' CI pipelines are completely independent. The order service team does not need to know how the user service is implemented; it only defines "what we need." The user service team does not need to know how the order service calls it; it only ensures that "our API satisfies the contract." The teams collaborate asynchronously through the Broker without blocking each other.

This is enormously liberating at the organizational level. Conway's Law says that system structure reflects organizational structure, and cross-team integration is one of the most painful parts of microservices. Contract testing turns "aligning APIs in meetings" into "aligning APIs in CI." The contract is the single source of truth: passing is green, failing is red.

8. Pact Broker: The Central Hub for Contracts

So far, contract files have only been generated and verified locally. In real team collaboration, contracts must move between consumers and providers. That is the role of Pact Broker.

Pact Broker is an independently deployed service that solves three problems:

First, contract sharing and discovery. After the consumer runs pnpm test, it uses pact publish to publish contracts to the Broker. Provider CI retrieves every contract targeting that provider and verifies it. There is no need to copy JSON files manually or share a Git repository.

bash
# Consumer side: publish contracts
pact-broker publish ./pacts \
  --consumer-app-version $(git rev-parse --short HEAD) \
  --broker-base-url https://broker.internal.company.com

# Provider side: fetch contracts from the Broker and verify them
pact-broker verify \
  --provider user-service \
  --provider-base-url http://localhost:8081 \
  --broker-base-url https://broker.internal.company.com

Second, version and tag management. The Broker records the consumer and provider versions associated with each contract. You can determine whether "the contract for frontend v2.3 has been verified successfully by backend v1.8." This is extremely useful when investigating which version introduced an incompatibility.

Third, and most importantly, the can-i-deploy compatibility check. This is the most valuable feature in the Pact ecosystem.

Before deployment, CI runs one command:

bash
pact-broker can-i-deploy \
  --pacticipant web-frontend \
  --version $(git rev-parse --short HEAD) \
  --to-environment production \
  --broker-base-url https://broker.internal.company.com

The Broker checks whether the contracts for the web-frontend version being deployed have been verified successfully by every relevant provider. If any provider has not verified the latest contract, the command returns a nonzero status, CI fails, and deployment is blocked.

This means an API change that has not been verified by both sides cannot enter production at all. This is the ultimate form of contract testing in CI/CD. It is no longer merely an action that "runs tests," but a deployment gate. The frontend and backend do not have to deploy simultaneously or be online at the same time, but compatibility is guaranteed by machines rather than relying on someone to remember, "We need to notify the frontend."

9. Limitations: What Contract Testing Does Not Do

It would be dishonest to present contract testing as a cure-all. Its capabilities have clear boundaries. If a team does not understand those boundaries, it will treat contract testing as a silver bullet and then be disappointed.

First, it does not verify business logic correctness. A contract defines "what the API looks like," not "whether its calculations are correct." GET /orders/:id may return an order with the correct structure but an incorrectly calculated total. Contract testing cannot detect that; unit tests must. Do not expect contract testing to guarantee business correctness.

Second, it does not verify complete cross-service workflows. Contracts are point-to-point, with one consumer and one provider. They do not verify state consistency across the entire "place order → deduct inventory → process payment → send notification" chain. A small number of critical-path integration or E2E tests are still necessary.

Third, it does not verify nonfunctional characteristics. Performance, concurrency, timeouts, retries, and idempotency are all outside the scope of contract testing. A response can take three seconds and still pass its contract.

Fourth, the contracts themselves carry maintenance costs. Consumer tests must be written, Provider States must be maintained, and data states must be created. When APIs change frequently, maintaining contracts also takes work. If the team does not make contract testing part of its CI routine, contracts will quickly drift away from the actual API and become another stale document.

An honest testing strategy is layered:

  • Unit tests: verify business logic; the largest and fastest layer
  • Contract tests: verify API agreements; a moderate number run on every commit
  • Integration tests/E2E: verify assembly of critical workflows; few in number and expensive

10. When to Adopt Contract Testing

Not every project should use Pact. The ROI of contract testing depends on your architecture and how frequently APIs change.

Good use cases:

  • Multiple services call one another and belong to different teams. Team boundaries are where contract testing delivers the most value because API alignment no longer requires meetings.
  • The frontend and backend are separated, and APIs evolve frequently. The frontend defines expectations and the backend verifies them, eliminating substantial integration time and stale API documentation problems.
  • The interface is a public API with multiple external consumers. You may not know who uses your API, but you can run every consumer's contract in your CI and immediately detect when any consumer expectation is broken.
  • Deployments are frequent and require automated compatibility gates. can-i-deploy is a genuine safety net for high-frequency deployments.

Poor use cases:

  • A monolithic application whose modules call functions directly. There is no API boundary for contract testing. Unit tests and module-level integration tests are sufficient.
  • The API is extremely stable and changes less than once every six months. The engineering cost of adopting Pact, including training, CI integration, and Broker deployment, far exceeds its occasional benefit. It is overkill.
  • There is only one consumer and one provider, both owned by one team. Starting docker-compose and running integration tests is more direct, and communication costs are already low. The benefits of contract testing at scale come from the network effects of multiple consumers and multiple providers.
  • The team does not yet have CI/CD infrastructure. Pact's value depends on CI automation. If automated tests are not running yet, solve that problem first.

My practical rule is simple: adopt contract testing when "changing one API means notifying every downstream team and helping each one integrate" starts consuming a significant amount of your time. Before then, do not introduce it merely because it seems "advanced."

Summary

  • Integration testing has an inverted cost-benefit ratio in microservices: every service must be started, interconnected data must be created, the environment constantly drifts, and feedback is too slow to help developers.
  • Contract testing verifies only one thing: whether the API contract has changed. It extracts compatibility checks from expensive integration testing and turns them into inexpensive tests that can run on every commit.
  • Pact's consumer-driven model is central: the consumer defines "what I expect you to return" → Pact generates a JSON contract → the provider replays requests and verifies them. The contract reflects what consumers actually need, not documentation written from provider assumptions.
  • Pact Broker plus can-i-deploy upgrades contract testing from "running tests" to a deployment gate. API changes that have not been verified by both sides cannot enter production.
  • Contract testing does not verify business logic, end-to-end workflows, or performance. It is one layer of a testing strategy, not the entire strategy. Use it for frequently changing APIs across multiple services and teams; avoid it for monoliths, stable APIs, and single-team systems.

Key takeaway: Contract testing is not about finding more bugs. It makes the impact of API changes visible before merge, turning "it broke downstream" into "downstream has already verified it."

Related

Related Content