API contract testing with Pact: consumer-driven workflows for polyglot services
Pact contract tests for HTTP APIs: consumer expectations, provider verification with states, broker workflows, and where they complement—not replace—integration tests.
You merge a small refactor to a JSON field name—userId becomes user_id for consistency with the database—and staging looks fine because your own UI and BFF were updated in the same release. Two days later, a partner integration starts failing: their mobile client still sends and expects camelCase, and nobody ran their suite against your branch. Integration tests inside your repo never modeled that external consumer, so the breakage shipped as “green.”
That class of failure is exactly what consumer-driven contract testing is meant to prevent. Instead of hoping every caller’s test environment tracks your main branch, you record what each consumer actually needs from an HTTP API (or a message schema) and verify that the provider still honors those expectations on every build. This article focuses on Pact as the de facto open-source implementation: how the workflow fits into CI, what it buys you compared to broader integration tests, and the operational habits that keep a contract suite trustworthy rather than noisy.
If you are standardizing how teams ship scalable, production-ready APIs—especially across language boundaries—the contract layer is often the cheapest place to encode “this shape is part of the public promise.”
Why integration tests alone miss cross-team breakage
End-to-end tests are valuable when they exercise real networks, databases, and auth. They are also expensive, flaky at scale, and usually owned by one side of the dependency. A downstream team rarely runs your service’s full stack on every pull request; they mock your HTTP responses or point at a shared staging environment that lags production.
Contract tests invert the dependency for verification purposes. The consumer defines the minimum acceptable response (status, headers that matter, JSON fields that must exist). The provider proves it can satisfy all registered consumers without hand-maintaining a giant shared Postman collection. The key insight is not “no more integration tests”—it is separating:
- Correctness of composition — Do the pieces work together in a realistic environment? (integration / e2e, fewer runs)
- Compatibility of interfaces — Did we change something a real caller relied on? (contracts, fast and parallel)
Contracts shine when you have many deployables, independent release cadence, or third parties who cannot join your monolithic test harness.
How Pact models expectations
Pact splits work into two roles:
- Consumer tests — In the service that calls the API, tests exercise the client code against a mock HTTP server generated from expectations you declare in code. Those expectations are serialized into a pact file (JSON) describing interactions: given a request like X, the provider must respond like Y.
- Provider verification — The provider service (or a slim harness around it) replays each interaction against the real application (often with provider states that seed data). If a response no longer matches the pact, verification fails.
The Pact Broker (hosted or self-managed) stores pact versions, computes can-i-deploy safety from verification results, and supports webhooks so provider builds run when a consumer publishes a new contract. You can start file-based in a monorepo, but teams that outgrow “email the JSON file” almost always adopt a broker for visibility and deployment gates.
Provider states: where “realism” enters
Pure structural matching is not enough when the response depends on database rows. Pact’s provider states are named hooks the consumer declares (“given user 42 exists”) and the provider implements (insert fixture, stub external id, etc.). They are not meant to reimplement entire domains—only the minimum world each interaction needs.
Trade-off: States can become a second maintenance surface. Keep them small, fast, and documented; avoid encoding business scenarios that belong in domain tests.
Versioning, branches, and deployment safety
Contracts interact badly with naive semver if teams treat every field as immutable forever. Pragmatic rules that work in consulting-style engagements:
- Prefer additive changes — New optional fields and backward-compatible enum extensions rarely break pacts if consumers only assert what they use.
- Coordinate breaking changes — Use explicit consumer version bumps, feature flags, or dual-write periods; update pacts in the same change train as the provider.
- Use the broker’s matrix — Tag consumer versions per git branch or environment so
can-i-deployanswers “is it safe to promote this provider build given these consumer contracts?”
Limitation: Pact verifies synchronous request/response (and message variants for queues) against recorded examples. It does not prove temporal properties (“event B always follows A within 5 s”) or full security posture—pair it with other test types.
Practical example: a minimal consumer test and provider verification
The following sketches are abbreviated but structurally faithful: a TypeScript consumer defines one interaction, generates a pact, and a Node provider verifies it with a single provider state. Adapt paths and test runners to your stack (Jest, Vitest, etc.).
Consumer side (Jest-style)
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
import path from "node:path";
const { like, eachLike } = MatchersV3;
describe("BillingClient pact", () => {
const provider = new PactV3({
consumer: "checkout-bff",
provider: "billing-api",
dir: path.resolve(process.cwd(), "pacts"),
logLevel: "warn",
});
it("returns an invoice summary for a known account", async () => {
await provider.addInteraction({
states: [{ description: "account acc_001 has one open invoice" }],
uponReceiving: "a GET for invoice summary",
withRequest: {
method: "GET",
path: "/v1/accounts/acc_001/invoices/summary",
headers: { Accept: "application/json" },
},
willRespondWith: {
status: 200,
headers: { "Content-Type": "application/json" },
body: {
accountId: like("acc_001"),
openCount: like(1),
currency: like("USD"),
items: eachLike({
id: like("inv_1001"),
totalCents: like(4999),
status: like("OPEN"),
}),
},
},
});
await provider.executeTest(async (mockServer) => {
const res = await fetch(`${mockServer.url}/v1/accounts/acc_001/invoices/summary`, {
headers: { Accept: "application/json" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.openCount).toBe(1);
expect(Array.isArray(body.items)).toBe(true);
});
});
});
The matchers (like, eachLike) tell Pact which fields are flexible (any string, any number) versus structural invariants you care about.
Provider verification (excerpt)
import { Verifier } from "@pact-foundation/pact";
import path from "node:path";
async function verifyBillingApi() {
await new Verifier({
provider: "billing-api",
providerBaseUrl: "http://127.0.0.1:4010",
pactUrls: [path.resolve(process.cwd(), "pacts/checkout-bff-billing-api.json")],
stateHandlers: {
"account acc_001 has one open invoice": async () => {
await seedInvoiceFixture({ accountId: "acc_001", invoiceId: "inv_1001", totalCents: 4999 });
return "Seeded acc_001 invoice";
},
},
requestFilter: (req, res, next) => {
// Example: inject service auth headers for local verification only
req.headers["x-internal-verify"] = "1";
next();
},
}).verifyProvider();
}
In CI you typically start the provider (or a slim app entry) on a loopback port, run verifyProvider, then tear down. With a broker, replace pactUrls with pactBrokerUrl and consumer version selectors so verification tracks the latest compatible consumer pacts automatically.
For a fuller picture of how HTTP APIs evolve without surprise outages, the ideas here complement a backward-compatibility mindset—see the About page for the broader engineering themes this site focuses on.
Common mistakes and pitfalls
- Over-matching snapshots — Asserting the entire JSON body as a literal brittle string causes churn on harmless ordering or whitespace changes. Prefer matchers on fields consumers read.
- States that hide incorrectness — A provider state that mocks out half the stack produces green verification that still fails in production. Keep verification close to real handlers; stub only true externals.
- No broker discipline — Teams check pact JSON into git but never fail provider builds when contracts drift. The result is false confidence. Either wire broker webhooks or enforce
pact:verifyin the provider pipeline on every main merge. - Treating contracts as documentation — Pact files are executable specs, not human prose. Maintain OpenAPI or Problem Details (RFC 9457) for narrative and edge cases contracts do not cover.
- Ignoring message/async flows — HTTP-only contracts will not catch a breaking change to an Avro schema on a topic. Use Pact message contracts where async boundaries are part of the public interface.
Conclusion
Pact-style contract testing closes the gap between “our tests pass” and “our callers still work” by making consumer expectations executable and provider verification routine. It is not a replacement for integration tests, load tests, or security review—but it is one of the highest-leverage gates for polyglot systems and independently deployed services. Invest in small provider states, matcher discipline, and a broker-backed workflow so contracts stay fast and trusted.
If you are evolving a public API surface and want a second pair of eyes on how contracts, deployment sequencing, and observability fit together, reach out via the contact form—particularly for teams tightening release safety without slowing down every merge.
Newsletter abonnieren
E-Mail erhalten, wenn neue Artikel erscheinen. Kein Spam — nur neue Beiträge von diesem Blog.
Über Resend. Abmeldung in jeder E-Mail möglich.