Skip to main content

API Testing

Contract Testing for APIs: Stop Breaking Your Consumers

Jovan RadivojevicJul 22, 20265 min read
Contract Testing for APIs: Stop Breaking Your Consumers

A backend team renames a field, ships it, and finds out three days later that a mobile client crashes on launch because it expected the old field name.

The API tests all passed. They were testing the backend's own understanding of itself, not what the consumer actually needed. That's the gap contract testing closes.

What a contract test actually checks

A contract test validates that a response matches an agreed schema (field names, types, required-vs-optional status) independent of the specific values returned. It answers one question: if a consumer built against this contract, does today's response still satisfy it?

{
  "id": "string, required",
  "email": "string, required",
  "createdAt": "string (ISO 8601), required",
  "isVerified": "boolean, required"
}

If a field is removed, renamed, or its type changes, the contract test fails immediately, against the schema, without needing a running consumer at all. Compare that to finding out through an end-to-end suite that only runs nightly, against a staging environment that may already be several deploys behind.

Provider-driven vs consumer-driven

There are two ways to define the contract, and they catch different failure modes.

  • Provider-driven. The API team publishes a schema (OpenAPI, JSON Schema, GraphQL SDL) and every response is validated against it. Fast to set up, and catches most accidental breaking changes.
  • Consumer-driven. Each consuming team defines what fields they rely on, and those expectations run against the provider in CI. This catches the case a provider-driven contract misses entirely: a field the schema still technically allows to be optional, but that one consumer has always treated as required.

Most teams get real value from provider-driven contracts alone. Consumer-driven contracts earn their setup cost once you have multiple independent consumers (a web app, a mobile app, a partner integration) that don't all use the same subset of the API.

What a consumer-driven contract looks like in practice

Pact is the tool most teams reach for here. The consumer writes down what it actually expects, once, and that expectation becomes an executable check against the real provider.

// Consumer side: record the expectation as a Pact interaction
await provider.addInteraction({
  state: "a user with id 42 exists",
  uponReceiving: "a request for that user",
  withRequest: { method: "GET", path: "/users/42" },
  willRespondWith: {
    status: 200,
    body: {
      id: 42,
      email: like("user@example.com"),
      isVerified: true,
    },
  },
});

That interaction gets published to a Pact broker as a contract file. The provider's CI pipeline then pulls every published contract and replays each one against the real API, failing the build if any consumer's expectation no longer holds, before that provider change ever reaches a shared environment.

# Provider CI: verify every consumer contract on every merge
- name: Verify consumer contracts
  run: pact-verifier --provider-base-url=http://localhost:4000 \
       --pact-broker-base-url=$PACT_BROKER_URL

The part teams underestimate: this only works if "can I deploy" is actually gated on it. A contract that fails and gets ignored because the deploy ships anyway isn't a safety net. It's a false sense of one.

Additive changes are safe. Breaking changes need a plan.

Contract testing catches breaking changes, but the better outcome is not making them in the first place. Most API changes can be additive:

  • Adding a new optional field never breaks an existing consumer that ignores unknown fields, which is why validating "no unexpected required fields" matters more than validating exact field lists.
  • Renaming a field is never truly additive. Expose the new name alongside the old one, let contract tests confirm both are honored, then deprecate the old name on a timeline consumers have actually agreed to.
  • Changing a field's type (a string ID becoming a number, a nullable field becoming required) breaks any contract test built around the original schema. That's exactly the case contract testing exists to catch before a consumer does.

Catching a breaking schema change before a contract test even runs

Full contract test execution is the most thorough check, but it needs a running provider and a broker in the loop. A cheaper first gate is a static schema diff, run on every pull request that touches an API definition, before any test suite executes at all.

# graphql-inspector: fails CI the moment a schema change is a breaking one
graphql-inspector diff old-schema.graphql new-schema.graphql --fail-on-breaking

The same idea applies to OpenAPI-described REST APIs with a spec-diff tool. This doesn't replace consumer-driven contract tests. A schema diff can't tell you that one specific consumer treats an optional field as required.

But it catches the obvious cases (a removed field, a type change, a newly required field) in seconds, on every PR, before a slower contract or end-to-end run even starts. Layer it in front of contract testing as a first filter, not instead of it.

Where this fits with the rest of your suite

Contract tests aren't a replacement for end-to-end tests. They answer "is the shape right," not "does the feature work." Run them on every API change, in seconds, as a merge gate, and keep a smaller end-to-end suite for the workflows that actually span multiple services.

Our API testing work usually starts here, because it's the cheapest layer to add and the one most teams skip. If you want to see the shape of a contract in practice, our GraphQL practice API is a reasonable stand-in for testing exactly this kind of schema validation, and pairs well with the habits in writing resilient API tests.