Consumer-driven contract testing with the cloud broker

Roy de Kleijn Roy de Kleijn · · 9 min read
Consumer-driven contract testing with the cloud broker

Contract tests catch the failure that integration tests miss and unit tests never see: a provider changed the shape of a response, and a consumer that reads it breaks. A consumer-driven contract flips the usual order. Instead of a provider publishing a spec and hoping everyone follows it, each consumer writes down exactly what it needs, and the provider has to keep meeting those needs. The cloud broker is where those contracts live, get verified, and decide whether a release is safe to ship.

This post walks the whole loop with a provider that really does drift between versions, so you can run every step yourself.

The example: one API, two versions that disagree

We use two public builds of the same Practice Software Testing API:

  • Good build: https://api.practicesoftwaretesting.com
  • Bugs build: https://api-with-bugs.practicesoftwaretesting.com

Both answer GET /products and GET /products/{id}, but the two builds return different shapes. That difference stands in for the drift a real provider introduces between releases.

Ask the good build for a product and you get:

{ "id": "01M6PHARK38BS3T6TWQFDKWYAP", "name": "Combination Pliers", "price": 14.15, "in_stock": true }

Ask the bugs build for the same thing and you get:

{ "id": 1, "name": "Combination Pliers", "price": 14.15, "stock": 3, "brand_id": 1 }

Three things changed:

  • id went from a string (a ULID) to an integer. A consumer that builds a link like /products/01M6... now has a number it did not expect.
  • in_stock, a boolean, is gone. The bugs build returns stock, a count, instead. A consumer that shows an "in stock" badge has nothing to read.
  • brand_id, category_id and product_image_id were added. These are harmless: a consumer that ignores unknown fields does not care.

A good contract passes the additive change and fails the two breaking ones. That is the point of matching by type and shape rather than by exact bytes.

1. Design the contract, consumer first

Open the Contract Designer in API Spector with Design a contract. A consumer-driven contract is authored up front, before any endpoint is wired in. You name the consumer, the provider, and the interactions the consumer depends on.

The Contract Designer: a consumer-driven contract for the products endpoint, matched by type

For the products list the interaction reads: given products exist, a GET /products returns 200 with a body that looks like the example. Leave Match a JSON example by type, not exact value switched on. That is what makes the contract tolerant: it checks that id is a string and in_stock is a boolean, not that the id is literally 01M6.... Tolerant matching is what lets the contract accept new fields while still catching a changed type.

2. Make it runnable, then check both builds

A design is only useful if you can run it. In the collection, each request carries a contract expectation: the status code it must return, and a body matcher describing the shape it must have. The matcher is an example annotated with types:

{
  "current_page": { "__match": "integer" },
  "data": {
    "__match": "eachLike", "min": 1,
    "value": {
      "id":       { "__match": "regex", "regex": "^[0-9A-HJKMNP-TV-Z]{26}$" },
      "name":     { "__match": "string" },
      "price":    { "__match": "number" },
      "in_stock": { "__match": "boolean" }
    }
  }
}

The request points at {{PRODUCTS_URL}}/products, and two environments fill that variable in:

  • good sets PRODUCTS_URL to https://api.practicesoftwaretesting.com
  • bugs sets it to https://api-with-bugs.practicesoftwaretesting.com

Switch the environment and run the contract. Against the good build every expectation is met. Against the bugs build the same contract fails twice: id is an integer where a string was required, and in_stock is missing. The added brand_id fields raise nothing, because the matcher only asserts what the consumer reads.

The contract panel, where you run a contract against the selected environment

Run it against the bugs build and the report says exactly why. Every product in the list trips the same two checks: id must be a string (it is an integer), and in_stock is required but missing. The extra fields are not mentioned, because the consumer never asked about them.

The verification report against the bugs build: each product fails because id must be a string and in_stock is required but missing

From the command line the two runs are one flag apart:

api-spector contract run --workspace ./ --mode consumer --environment good
api-spector contract run --workspace ./ --mode consumer --environment bugs

This example ships as a workspace you can open directly. It lives in the desktop repository under examples/consumer-driven-contracts/: a collection with the two requests, the good and bugs environments, and the compiled pact.

3. Publish the contract to the cloud broker

Running locally proves the contract against whatever build you point at. The broker turns it into a shared, durable fact. From the Contract Designer, choose Publish to Cloud. API Spector compiles the design to a Pact document and publishes it under the consumer, the provider, and a consumer version. A git SHA makes a good version.

The broker speaks the Pact Broker API, so this is not a lock-in. The same contract can be published from CI with the standard pact-broker client, and the broker can verify it against a provider's OpenAPI spec (bi-directional contract testing) as well as against a recorded provider run.

4. Read the broker: who depends on whom, and is it green

The deployment matrix is the one screen that answers "is this integration safe". Every consumer and provider pair, the latest verification, and where each side is deployed.

The deployment matrix: pst-web verified against products-api 5.0.0 and failing against 6.0.0-rc1

Here pst-web 2.3.0 is verified against products-api 5.0.0, the good build, and failing against products-api 6.0.0-rc1, the bugs build. Open the contract to see why: the two interactions the consumer depends on, and a verification history with one pass and one failure.

The contract detail: two interactions, verified against 5.0.0 and failing against 6.0.0-rc1

5. Gate the deploy

A verification that nobody reads is just a report. The deploy gate turns it into a decision. You ask the broker whether a version is safe to release into an environment, given everything already live there.

Deploy check: the bugs release is blocked from production

Promoting products-api 6.0.0-rc1 to production is blocked, because the version currently depended on in production, pst-web 2.3.0, has no successful verification against it. In CI the same question is a single command:

api-spector contract deploy-check --broker --pacticipant products-api --environment production --version "$GIT_SHA"

A safe version exits zero and the pipeline continues. An unsafe one exits non-zero (the broker answers HTTP 409), and the promotion stops before it reaches a user.

Wire it into the pipeline

The loop has three touch points in CI, and none of them need a shared test environment. Authenticate with API_SPECTOR_TOKEN and use the git SHA as the version:

# consumer build: publish what pst-web needs
api-spector contract publish --workspace ./ --consumer pst-web --provider products-api --version "$GIT_SHA"

# provider build: publish the spec so every consumer is re-verified
api-spector contract publish-spec --provider products-api --spec ./openapi.yaml --broker --version "$GIT_SHA"

# before promoting either side: gate on the broker
api-spector contract deploy-check --broker --pacticipant pst-web --environment production --version "$GIT_SHA"

Why contract testing is worth it

Running the loop above takes a few minutes. Here is what it buys a team that keeps doing it.

  • No shared test environment. The consumer checks its contract against a running provider, a recorded response, or the provider's OpenAPI spec. You do not need every service deployed together to find out that two of them disagree, which removes the slowest and flakiest step in most integration pipelines.
  • Failures land on the build that caused them. When the provider changed id from a string to an integer, the break showed up on the provider's own CI run, named the exact field, and pointed at the consumer that depends on it. Nobody had to reproduce it in production or wait for a nightly end-to-end run.
  • Additive changes stay cheap. Because the contract matches by type and shape, the provider could add brand_id, category_id and product_image_id without breaking anyone. A response can evolve freely, as long as it keeps the promises consumers actually rely on.
  • Each side ships on its own schedule. The broker and the deploy gate coordinate releases, so the consumer and the provider do not have to deploy together. deploy-check answers "is this version safe against what is already live", and the pipeline stops before a mismatch reaches users.
  • Only real dependencies are enforced. A consumer-driven contract describes what one consumer reads, not the whole API surface. The provider is free to change anything no consumer depends on, and is told the moment a change touches something that is used. That is far less brittle than asserting a full schema nobody reads end to end.
  • The contract is living documentation. The set of published pacts is an accurate, executable record of who depends on whom and on which fields. The matrix shows that state at a glance, instead of it living in one engineer's memory.
  • It scales with the number of services. One consumer and one provider is easy to reason about by hand. Thirty of them is not. The matrix and the deploy gate give a single, automatable answer to "is this change safe to release here", no matter how many pairs are involved.

The loop, start to finish

A consumer writes down what it needs and matches it by type, so additive provider changes pass and breaking ones fail. The contract runs locally against any build, then publishes to the broker. The broker verifies it, shows the state in the matrix, and answers one question for CI: is this version safe to deploy here. The bugs build never reaches production, not because someone remembered to test it, but because the contract said no and the gate listened.

Try it in API Spector

Free and open source. No account needed to use the app.

Keep reading