The Interaction, Not the Endpoint: What a Consumer Records
Your checkout service calls GET /orders/{id} on the orders API. The orders team deploys a change that renames total to amountCents, the OpenAPI file is updated in the same PR, every schema check in their pipeline stays green — and checkout now renders undefined, because the spec says what the provider offers, not what each consumer actually reads. Consumer-driven contract testing closes that gap: the consumer records the interactions it depends on, and the provider proves it still satisfies them before it ships.
const { Pact } = require("@pact-foundation/pact");
const provider = new Pact({ consumer: "orders-web", provider: "orders-api", port: 8081 });
describe("orders-api contract", () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
it("returns a paid order by id", async () => {
await provider.addInteraction({
state: "order 6f1c0a2e exists with status paid",
uponReceiving: "a request for that order",
withRequest: {
method: "GET",
path: "/orders/6f1c0a2e-4b7d-4f21-9a3e-1c2d3e4f5a6b",
headers: { Authorization: "Bearer eyJhbGciOi..." },
},
willRespondWith: {
status: 200,
headers: { "Content-Type": matching(/application\/json(;charset=utf-8)?/) },
body: {
id: Like("6f1c0a2e-4b7d-4f21-9a3e-1c2d3e4f5a6b"),
status: Like("paid"),
total: Like(42.5),
items: EachLike({ sku: Like("SKU-1"), qty: Like(2) }),
},
},
});
const res = await fetch(`${provider.mockService.baseUrl}/orders/6f1c0a2e-4b7d-4f21-9a3e-1c2d3e4f5a6b`);
expect(res.status).toBe(200);
expect((await res.json()).total).toBeTypeOf("number");
});
});
Two design choices in that block decide whether this survives contact with a real backlog. Matchers, not literals: Like(42.5) asserts the field exists and is a number — the value in the generated file is a type example, so a test order whose total differs still passes. Without matchers the pact becomes a recorded fixture that fails whenever data changes, and the team learns to ignore it. Provider states, not setup inside the test: given: "order ... exists with status paid" is a message the provider turns into its own database fixture, so the contract stays portable across environments.
Running it produces the pact — a JSON artifact that is the contract:
{
"consumer": { "name": "orders-web" },
"provider": { "name": "orders-api" },
"interactions": [{
"description": "a request for that order",
"request": { "method": "GET", "path": "/orders/6f1c0a2e-..." },
"response": { "status": 200, "body": { "id": "6f1c0a2e-...", "status": "paid", "total": 42.5 } }
}],
"metadata": { "pactSpecification": { "version": "3.0.0" } }
}
The consumer commits its pact to the broker on every merge; the provider verifies it against its real code. That is the whole mechanism, and it is a common interview topic in microservices roles — the answer interviewers want is "the consumer's expectations are published as an artifact, and the provider is verified against them in its own pipeline", not "it's like a mock".
Note what the file records about secrets: the Authorization header value is written into the pact like any other field. The fix is to record a fake token as the example (Bearer eyJhbGciOi...) with a regex matcher, so the contract asserts the header is present and shaped like a bearer token while no real credential leaves CI — and anyone with broker read access can inspect the contract without holding a production token. Requests follow the same rules as responses: a POST /orders interaction matches on path and required body fields with Like(...), never on exact JSON serialization — key order, whitespace, and null-versus-absent are all matcher decisions, and getting one wrong is the fastest route to a suite people re-run until it goes green. When a consumer test fails locally, the output compares the request the mock received against what the interaction expected: either your client genuinely sent the wrong path, or a matcher is missing. Both are useful; neither is a flake. It runs on top of the same spec discipline from OpenAPI Spec-Driven Testing, but expresses behavior the spec cannot: header names, error semantics, and the exact fields one client reads.
The Broker and can-i-deploy: Failing the Pipeline Before the Client Breaks
A pact file sitting in a consumer's CI directory protects nobody. The pact has to reach the provider's pipeline, and somebody has to say no when verification fails — that somebody is the Pact Broker, a small database plus API that stores every consumer-provider pair, versions each side, and answers one deploy-blocking question.
# run it locally in seconds
docker run --rm -p 9292:9292 pactfoundation/pact-broker:latest \
PACT_BROKER_DATABASE_ADAPTER=sqlite \
PACT_BROKER_BASE_URL=http://localhost:9292
# consumer side: publish after every green build
pact publish --broker-base-url http://localhost:9292 \
--consumer-app-version "$GIT_SHA" --tag main
Provider side, in the orders-api pipeline, verification runs against the real application (started with test fixtures that implement each state) and reports back:
pact verify \
--provider-base-url http://localhost:8081 \
--pact-urls "$PACT_URL" \
--publish-verification-results \
--provider-version "$GIT_SHA"
Publishing the result is what makes the system bidirectional: the broker now knows that at provider version abc123, consumer orders-web's expectations passed. Every deploy is then gated by one command whose exit code is the gate:
pact-broker can-i-deploy --pacticipant orders-api --to-environment staging
# 200 -> safe to deploy (all relevant pacts verified at this or a later version)
# 1 -> NOT SAFE: consumer X expects total, provider sends amountCents
Wire it in as the last step of the provider's deploy job — exit code non-zero aborts the release while it still costs a rebuild, instead of a client incident. The reverse command (--pacticipant orders-web --to main) tells a consumer whether its deployed version is compatible with the provider's latest, which is how you keep a front-end from merging against an API that has not shipped yet.
Two broker features keep this workable at scale. Tags and environments (--tag staging, --to-environment staging) scope the question to what is actually running rather than to every historical pact — tag main on merge, tag prod when a release lands, and query --to main so a stale-but-verified old pact never counts as safety. Pending/WIP pacts (--enable-pending-pacts) let a consumer publish a new expectation without instantly breaking every provider build — the pact is verified but its failure does not fail until the consumer leaves WIP status — which is how you avoid the death spiral where one team's aspirational contract freezes three other teams' releases.
The pipeline wiring is deliberately boring, and the ordering matters — verify first, gate second:
- name: Verify all published pacts against this build
run: pact verify --provider-base-url http://localhost:8081 \
--pact-urls "$PACT_URL" \
--publish-verification-results --provider-version "$GIT_SHA"
- name: May this version deploy to staging?
run: pact-broker can-i-deploy --pacticipant orders-api --to-environment staging
# exit code 1 stops the job here — before the deploy, not after
Because the broker now sits on your deploy path, treat it like production infrastructure: self-host it with a real database (not the local SQLite demo), put auth and TLS in front of it, and decide explicitly whether a broker outage fails deploys closed (safe, blocks releases) or open (available, loses the guarantee). The teams that get contract testing right make that a written decision instead of an accident of retry behavior.
For scale math, this replaces environments: with 8 consumers and 5 providers, pairwise integration needs up to 40 service combinations maintained, while contracts need 8 consumer tests plus 5 provider verifications. That trade-off — and its limits — is the next chapter's job.
Contract vs Schema vs Integration: What Pact Replaces and What It Never Will
"We have contracts now, do we still need integration tests?" is a standard QA interview question, and the only credible answer is a boundary statement: contracts verify one specific expectation per interaction, and everything outside that boundary still needs another layer.
| Layer | Verifies | Needs all dependencies up | Catches | Cannot catch |
|---|---|---|---|---|
| JSON Schema / OpenAPI | payload shape | no | missing, renamed, mistyped fields | cross-field semantics |
| Consumer contract (Pact) | each client's real expectations: path, headers, body, errors | no — runs against a stub | provider changes that break a specific consumer | performance, DB state, flows nobody recorded |
| End-to-end integration | the whole path incl. DB, queue, auth | yes, everything | anything in that path, including emergent behavior | cheaply, repeatably — it is slow and environment-flaky |
The economics: integration environments are the most expensive, least reliable thing in a pipeline (a shared staging stack that flakes 5% of runs will fail your suite far more often than your code fails), while contracts run in milliseconds against nothing. So the standard shape is contracts for every consumer-provider pair on every PR, a thin end-to-end suite nightly, and integration tests reserved for the paths where ordering, transactions, or queues genuinely matter.
Know the failure modes before you adopt it, because each one turns the gate into noise:
- Unstructured data. A
Like("paid")on a timestamp or a random id makes pacts fail intermittently; use matchers (or ignore-regexp matchers) for anything generated, and structural data for anything user-created. - States implemented as global fixtures. If
state: "order exists"seeds one shared database, parallel verification corrupts itself — each state must be idempotent and scoped to the run. - Contracts that do not describe what you read. A pact only fails when the provider changes something a consumer asserted. If checkout never asserted
total, no pact will ever protecttotal— which is why auditing what your pacts actually cover is a review activity, not a tool setting. - Ignoring the exit code.
can-i-deployin a job that nobody watches is decoration; it must be the required deploy step, with an owner when it blocks.
Two extensions answer the follow-up questions interviewers ask once you describe the basic flow. Queues: Pact is not HTTP-only — for a Kafka or RabbitMQ consumer, the producer publishes a message pact (payload plus headers) and the broker verifies that the producer can emit what the consumer's parser accepts, so asynchronous flows get the same N+M economics instead of an all-services integration environment. Change negotiation: when a consumer needs a new field, the expectation is published from its repository in WIP status, the provider sees the failing pact without its build going red, implements it, and the pact leaves WIP — the whole negotiation is timestamped in broker history, which settles the "who agreed to this?" argument months later.
Also keep the layers honest about overlap: a schema check runs on every response in your own suite, a contract runs before deploy against every consumer's expectations, and an end-to-end run proves the wiring. They are complements with different cost profiles, not competitors — the test-pyramid framing interviewers look for. The last piece of the contract story is what happens when the contract itself must evolve across versions, which is API Versioning and Compatibility.
Quiz
1. A consumer's pact asserts `total: 42.5` literally. Test orders with a total of 19.99 fail the consumer test. What is the fix?
2. What does `pact-broker can-i-deploy --pacticipant orders-api --to-environment staging` decide?
3. With 8 consumers and 5 providers, how do pairwise integration environments compare with contract testing in CI?
4. Your pacts are green for months, yet a provider change still broke checkout's totals. What is the most likely cause?
Flashcards
Question
What is a consumer-driven contract, in one sentence?
Click to reveal answer
Answer
A machine-readable record of the requests one consumer sends and the response it requires (path, method, headers, body with matchers), published by the consumer and verified by the provider in the provider's own pipeline.
Question
Why do pacts use matchers (Like, EachLike, matching) instead of literal values?
Click to reveal answer
Answer
So the contract asserts structure, type, and format rather than one recorded example: Like(42.5) means 'a number exists here', EachLike means 'an array of objects shaped like this'. Literals turn pacts into fixtures that fail on any data change and get ignored.
Question
What does a Pact Broker store, and what question does can-i-deploy answer?
Click to reveal answer
Answer
It stores published pacts plus verification results keyed by consumer and provider version/tag/environment. can-i-deploy asks: for this participant's version, do all relevant pacts verify against the other side's deployed version? Exit 0 = safe, exit 1 = a published expectation would break.
Question
What is a provider state and why can't the consumer set it up itself?
Click to reveal answer
Answer
A state ('order exists with status paid') is data the *provider* must create in its own system before verification — the consumer has no access to the provider's database or fixtures. States must be idempotent and scoped so parallel verifications don't collide.
Question
Contract vs schema vs integration testing: name the unique strength of each.
Click to reveal answer
Answer
Schema/OpenAPI: payload shape, cheapest, runs on every response. Contract (Pact): what each specific consumer reads — headers, errors, fields — without running the pair. Integration/E2E: emergent behavior across DB, queue, and auth, at the cost of full environments and flakiness.
Revision Notes
Key Takeaways
- 1. Contracts publish consumer expectations as artifacts; the provider verifies them in its own pipeline — that is the mechanism, and it runs without either service's full stack
- 2. Matchers keep pacts structural: assert type and presence, never one recorded value, or the gate becomes noise
- 3. The broker turns verification into a deploy gate: can-i-deploy's exit code is the only thing standing between a breaking change and production
- 4. N×M pairwise integration collapses to N+M verifications — the economic argument that justifies the tooling overhead
- 5. Contracts complement rather than replace schemas (shape) and integration (emergent behavior); the pyramid framing is the expected interview answer
Interview Tips
- • "What is consumer-driven contract testing?" — the consumer records what it needs, publishes it, the provider verifies against real code before deploying; contrast it with mocks (which never check the provider) and with the OpenAPI spec (which describes offer, not use)
- • "When would you choose Pact over end-to-end tests?" — for cross-service expectations on every PR: milliseconds, no environments; keep a thin E2E suite for transactional/queue paths contracts can't see
- • "How do you prevent a breaking provider change from reaching production?" — publish verification results, make `can-i-deploy` a required deploy step with exit-code enforcement, scope by tag/environment
- • "Why are your pacts green but clients still break?" — coverage equals assertions: if nobody recorded the field, nothing guards it; also check for literal values that were loosened to stop flaking
Cheat Sheet
Consumer-Driven Contract Testing (Pact)
Flow: consumer test → pact file → publish to broker → provider verifies real code → publish result → can-i-deploy gates release.
Consumer test shape:
await provider.addInteraction({
state: "order 6f1c exists with status paid",
uponReceiving: "a request for that order",
withRequest: { method: "GET", path: "/orders/6f1c", headers: { Authorization: "Bearer ..." } },
willRespondWith: { status: 200, body: { id: Like("6f1c"), status: Like("paid"), total: Like(42.5) } },
});
Broker commands:
pact publish --broker-base-url $BROKER --consumer-app-version $GIT_SHA --tag main
pact verify --provider-base-url http://localhost:8081 --pact-urls $PACT_URL \
--publish-verification-results --provider-version $GIT_SHA
pact-broker can-i-deploy --pacticipant orders-api --to-environment staging # exit != 0 blocks deploy
Scale: N consumers × M providers → N×M integration environments, or N consumer tests + M provider verifications.
Failure modes: literal values instead of matchers · global (non-idempotent) provider states · pacts that don't assert the fields you actually read · ignoring the can-i-deploy exit code · no WIP/pending pacts, so new expectations break everyone instantly.
Coverage rule: a pact fails only when the provider changes something the consumer asserted — green pacts mean "nothing we described changed", not "nothing broke".