Skip to content
yutils

How Contract Testing Actually Works

Why microservice integration tests don't scale, consumer-driven contracts (Pact), provider verification, contract as a Git-versioned spec, and how it replaces the 'spin up 20 services for one test' nightmare.

~9 min read

20 microservices. Service A's client breaks after Service B changes its API format. All integration tests pass — why? Because A's tests didn't see B's new format, and B doesn't know A's usage patterns. Contract testing closes that gap. This guide covers consumer-driven contracts and how Pact actually works.

Limits of Microservice Integration Tests

Naive approach:
  Service A test:
    - actually spin up service B (or stub its deps)
    - HTTP call → verify response

Problems:
1. To run B in CI you also need B's deps (B → C → D → DB)
2. Until B's new version deploys, A's tests don't verify the new format
3. 20 services → one test spins up 20 services → minutes long + flaky
4. Inter-service dependency cycles?

→ The "spin up 20 services for one test" cost and unreliability nightmare

Contracts — A Two-Sided Agreement

Contract = a spec of how one service uses another's API.

Example (Pact):
{
  consumer: "service-a",
  provider: "service-b",
  interactions: [
    {
      description: "get user by id",
      request: {
        method: "GET",
        path: "/users/42",
        headers: {"Accept": "application/json"}
      },
      response: {
        status: 200,
        body: {
          id: 42,
          name: matcher.string("Alice"),  // not exact value, type
          email: matcher.regex(/.+@.+/)
        }
      }
    }
  ]
}

→ Stored as JSON. Git-versioned. Shared via Pact Broker (or git).

Consumer-Driven Contract Testing

Step 1 — Consumer (A) generates the contract

  test("get user from B", () => {
    pact.given("user 42 exists")
        .uponReceiving("a GET")
        .withRequest({method: "GET", path: "/users/42"})
        .willRespondWith({status: 200, body: {id: 42, name: "Alice"}});

    // Run A's code — Pact's mock server responds per contract
    const user = await getUserFromB(42);
    expect(user.name).toBe("Alice");
  });

  → On pass, the contract JSON is generated → uploaded to Pact Broker

Step 2 — Provider (B) verifies the contract

  pact.verifyProvider({
    providerName: "service-b",
    providerBaseUrl: "http://localhost:8080",
    pactBrokerUrl: "...",
    stateHandlers: {
      "user 42 exists": () => db.users.insert({id: 42, name: "Alice"})
    }
  });

  → With B actually running, every interaction in the contract is invoked → verified.
  → If B's new changes break the contract → CI fails.

→ Both steps passing = A↔B integration guaranteed (without running both together).

What "Consumer-Driven" Means

Not "provider (B) writes the spec and consumer (A) follows" — consumer (A) declares "this is how I use it" and provider (B) must satisfy it.

Benefits:
- B learns which fields are actually used
- B can safely remove unused fields (after verifying no consumer uses them)
- A specs only what it needs — avoids overspecification

vs Schema-first (OpenAPI):
- OpenAPI: B says "I provide this API" → A consumes as it likes
- Pact: A says "this is how I use it" → B satisfies it
- Both valuable — OpenAPI for public APIs, Pact for internal service-to-service

Provider States — the "given" Clause

Contract test trap — what if "user 42 exists" isn't true at verify time?

Fix: provider state (Pact's given clause):
  consumer test:
    pact.given("user 42 exists")
        .uponReceiving("get user")
        ...

  provider verification:
    stateHandlers: {
      "user 42 exists": async () => {
        await db.clear();
        await db.users.insert({id: 42, name: "Alice"});
      },
      "user 42 does not exist": async () => {
        await db.clear();
      }
    }

→ Provider sets up that state before verifying. Matches consumer's assumption.

Matchers — Flexible Verification

Too-strict contracts break when the provider changes timestamps etc.
Matchers verify type/format only, allow value freedom:

response: {
  status: 200,
  body: {
    id: matcher.integer(42),         // any integer is OK (42 is an example)
    name: matcher.string("Alice"),    // any string
    created_at: matcher.iso8601DateTime(),  // any ISO datetime
    email: matcher.regex(/^.+@.+$/),
    items: matcher.eachLike({id: integer(), price: decimal()})
  }
}

→ Allows provider's exact-value variability (timestamps, ids), preserves structure.

Pact Broker — Sharing + Workflow

Self-host or SaaS (PactFlow).

Features:
- Store contract JSON (per consumer/provider)
- Version tags (main, staging, production)
- "can-i-deploy?" query — "Is service A v3 compatible with B in production?"
- Dep graph visualization (which service depends on which)
- Change webhooks (new consumer contract → auto-trigger provider CI)

CI integration:
  - Consumer build → publish contract → tag "ready for verification"
  - Provider build → pull contract → verify → tag "verified"
  - Pre-deploy: can-i-deploy → only ship if both sides compatible

Contract vs Integration vs E2E

What's verifiedMethodCost
One function / classUnitCheap
One service incl. DBIntegrationMedium
Service A ↔ Service B contractContract (Pact)Cheap (no need to run both)
End-to-end user flowE2EVery expensive

Common Pitfalls

  • Too-strict contracts — without matchers, exact-value comparison breaks on small provider changes. Use matchers.
  • Missing provider states — without "user 42 exists" preconditions, verifies fail due to DB state.
  • Ignoring dependency graph — A→B→C contracts don't cover A→C direct calls. Contract per dep.
  • Contracts as the only verification — contracts verify interfaces only. Business logic / performance still need separate tests.
  • Operating without a broker — sharing JSON via git works but loses version tags / can-i-deploy workflow.

Wrap-up

Contract testing — "guarantee integration between two services without running both". Consumer-driven means "spec only how I actually use it" → fewer false positives.

Practical — worth adopting at 5+ microservices. Pact for internal service-to-service, OpenAPI for external APIs. The Pact Broker + can-i-deploy workflow is core to safe deploys.

Back to guides