Skip to content
C Codeloom
Testing

Contract Tests Explained: Catching Integration Bugs Early

Understand consumer-driven contract testing, how it differs from integration tests, and how tools like Pact prevent breaking API changes between services.

·4 min read · By Codeloom
Intermediate 7 min read

What you'll learn

  • What a contract test verifies
  • How consumer-driven contracts work
  • The role of a broker
  • Writing a Pact-style contract
  • When contract tests beat full integration

Prerequisites

  • Familiar with testing concepts
  • Basic understanding of HTTP APIs

What and Why

In a microservices world, Service A calls Service B. If team B changes a JSON field from userId to user_id, Service A breaks — but no unit test catches it, because the unit tests on each side mock the other. Integration tests catch it, but they need both services running, are slow, and often live in a separate repo.

Contract tests fix this. A contract is a small, versioned document describing what the consumer expects from the provider: “When I send GET /users/42, I expect a 200 with a JSON body containing id and email.” Both sides verify against the contract independently. No shared environment required.

Mental Model

Think of a contract as a written agreement between two services. The consumer writes it (“here is what I assume”), and the provider verifies it (“yes, I deliver that”). If the provider changes shape without updating the contract, verification fails — and the breakage is caught before deploy.

This is consumer-driven: consumers define what they actually use, so providers do not waste effort guaranteeing fields nobody reads. A central broker stores contracts and tracks which versions are compatible.

Hands-on Example

Using Pact in Node.js, the consumer side looks like:

const { PactV3 } = require('@pact-foundation/pact');
const provider = new PactV3({ consumer: 'WebApp', provider: 'UserService' });

provider
  .uponReceiving('a request for user 42')
  .withRequest({ method: 'GET', path: '/users/42' })
  .willRespondWith({
    status: 200,
    body: { id: 42, email: 'jane@example.com' },
  });

await provider.executeTest(async (mock) => {
  const user = await fetchUser(mock.url, 42);
  expect(user.email).toBe('jane@example.com');
});

Running this generates a JSON contract file. You publish it to the broker. The provider then runs its verification suite, which replays the contract against a real instance of UserService.

 Consumer test         Broker          Provider test
   |                    |                   |
   | -- publish ------> |                   |
   |                    | <-- fetch ------- |
   |                    |                   |
   |                    | -- verify ------> |
   |                    | <-- result ------ |
   |                    |                   |
 pass/fail           records          pass/fail
Consumer-driven contract test flow

If UserService renames email to emailAddress, provider verification fails immediately. The broker also offers can-i-deploy checks — CI asks “can WebApp v3.4 deploy to prod given UserService v2.1?” and gets a yes or no.

Common Pitfalls

  • Treating contracts as full schemas: contracts should describe only what the consumer actually reads, not every field the provider returns.
  • Skipping verification on the provider: a contract is worthless if nobody verifies it on the producing side.
  • Pinning to exact values: use matchers (like, term) so contracts do not break when timestamps or IDs change.
  • No broker, just shared files: a broker tracks versions and compatibility; without it you lose can-i-deploy.
  • Confusing with integration tests: contracts do not test real network calls between live services — they test that each side honors a shared shape.

Practical Tips

Start with one pair of services that talk frequently and break often. Get the consumer test green, publish to the broker, wire up provider verification, then add can-i-deploy to CI. Keep contracts small; one interaction per scenario. Tag pacts with the consumer version so old contracts can be retired. And document the workflow: contract testing is a team sport, and silent breakage often comes from one side ignoring the broker.

Wrap-up

Contract tests give you the safety of integration testing without the cost of shared environments. They shine in distributed systems where teams ship independently. Adopt them when “we broke an upstream caller” becomes a recurring incident — the payoff is fewer surprise outages and faster, more confident deploys.