Contract Testing Replaces the Worst E2E Tests You've Ever Written

Microservices made the “run the whole stack to test it” approach intractable — too slow, too flaky, too much shared infrastructure. Contract testing flips the model: each consumer declares what it expects from a provider, and each provider verifies it meets those expectations. No shared environment, no end-to-end orchestration, full deploy independence — if both sides green, they're compatible.

🤔 Sound familiar?
  • Deploying service A breaks service B in production, and your “integration env” missed it
  • Teams freeze deploys around release windows to avoid cross-service breakage
  • You have a 200-test E2E suite that takes 40 minutes and tells you very little
  • API specs drift from reality between versions

Consumer-Driven Contracts with Pact give you the breakage signal early, in CI, on each side of the boundary independently.

How Pact Works


flowchart LR
    subgraph Consumer
      CT[Consumer test<br/>describes expected request/response]
      CT -->|generates| PACT[(pact.json)]
    end
    PACT -->|publish| BROKER[Pact Broker / PactFlow]
    subgraph Provider
      BROKER -->|fetch| PV[Provider verifies<br/>against running service]
      PV -->|results| BROKER
    end
    BROKER -->|can-i-deploy?| DEPLOY[CD pipeline]
      

The Consumer Side

On the consumer, you write a test against a Pact mock server. The test exercises your real client code; Pact captures every interaction (request + expected response) into a JSON pact file.

import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike } = MatchersV3;

const provider = new PactV3({
  consumer: 'web-checkout',
  provider: 'orders-api',
  dir: './pacts',
});

it('GET /orders/:id returns an order', async () => {
  await provider
    .given('an order with id 123 exists')
    .uponReceiving('a request for order 123')
    .withRequest({ method: 'GET', path: '/orders/123' })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      body: like({
        id: '123',
        status: 'paid',
        items: eachLike({ sku: 'X', qty: 1, price: 9.99 }),
      }),
    })
    .executeTest(async (mock) => {
      const client = new OrdersClient(mock.url);
      const order = await client.getOrder('123');
      expect(order.status).toBe('paid');
    });
});

Notice the matchers: like and eachLike describe shape, not exact values. The contract says “there's a numeric qty”, not “qtyis exactly 1”. The provider is free to return any value of the right type.

The Provider Side

The provider runs its real service against the pact file and replays each interaction. Provider states (the given clauses) are set up by handlers that seed the database appropriately before each verification.

import { Verifier } from '@pact-foundation/pact';

await new Verifier({
  provider: 'orders-api',
  providerBaseUrl: 'http://localhost:3000',
  pactBrokerUrl: 'https://pact.example.com',
  publishVerificationResult: true,
  providerVersion: process.env.GIT_SHA,
  consumerVersionSelectors: [
    { mainBranch: true },           // verify against consumers' main
    { deployedOrReleased: true },   // and what's in prod / pre-prod
  ],
  stateHandlers: {
    'an order with id 123 exists': async () =>
      db.orders.insert({ id: '123', status: 'paid', items: [...] }),
  },
}).verifyProvider();

can-i-deploy — The Safety Gate

The Broker tracks which versions of which services are compatible. Before deploying, ask the Broker:

# Block the deploy unless this version is verified against all
# deployed consumers (and provider, if we're the consumer side)
pact-broker can-i-deploy \
  --pacticipant orders-api \
  --version "$GIT_SHA" \
  --to-environment production

This is where contract testing earns its keep — it turns “is this safe to ship” from a judgment call into a yes/no answer from the system.

Bidirectional Contract Testing

Pact's 2024+ Bi-Directional feature lets the provider use any spec source (OpenAPI doc, generated schema) instead of literally executing the pacts. The Broker checks consumer expectations against the provider's spec. This is the realistic path when the provider team can't adopt Pact (legacy, vendor, etc.).

When Contract Testing Helps Most

  • Microservices owned by different teams with independent deploys.
  • Public or partner APIs where you can't coordinate releases.
  • Mobile and SPA clients consuming a backend — Pact pacts replace the “we changed the API and broke iOS” surprise.
  • Replacing flaky end-to-end happy-path tests that only verify the API contract.

When It Doesn't Help

  • Monoliths with one deployable. The CI test suite already covers everything.
  • Event-driven systems with complex semantic guarantees (use schema registries + compatibility checks; Pact supports messaging but it's a different model).
  • Business workflow correctness — contracts verify the wire format, not the meaning. You still need E2E or end-state tests for that.

What Each Layer Catches

LayerCatchesMisses
UnitLogic bugs in one componentInteraction bugs
IntegrationDB / framework wiringCross-service contracts
ContractWire-format breakage between servicesSemantic correctness, business flows
E2EWhole-system flows, UI behaviorSlow, flaky, expensive

Tooling Beyond Pact

  • Spring Cloud Contract — Pact-like for the Java/Spring ecosystem.
  • OpenAPI + Schemathesis / Dredd — schema-first; consumer-blind but useful if you have a strong API-first culture.
  • Buf / Protobuf compatibility — for gRPC, schema evolution checks at compile time.
  • JSON Schema + AsyncAPI — for event payloads on a message bus.

Pitfalls

Over-specifying contracts

If the consumer asserts on the exact UUID format, an order ID change breaks the pact even though no real consumer cares. Use matchers liberally. Specify what you depend on; don't snapshot the entire response.

Provider states that drift

Provider state handlers must remain in sync with the consumer's assumptions. When the consumer changes “an order exists” to “an order in state paid exists”, the provider handler must also change. Treat state handlers as part of the API surface.

Ignoring can-i-deploy in CD

Adopting Pact without wiring can-i-deploy into the deploy pipeline gives you most of the cost and little of the benefit. The broker check is what enforces the contract in practice.