Contract testing for microservices: Pact vs OpenAPI schema validation
Consumer-driven contracts with Pact and provider-side OpenAPI validation solve different problems. When each fits, how they combine, and a CI setup that blocks breaking changes.
Integration bugs between services rarely come from complex logic. They come from small, boring mismatches: a field renamed from accountId to account_id, an amount that used to be a number and is now a string, a 404 that became a 200 with an empty body. Each service's own tests pass, end-to-end tests are too slow and flaky to cover every pair of services, and the mismatch reaches production.
Contract testing checks the agreement between two services in isolation, quickly enough to run on every commit. There are two main approaches: consumer-driven contract testing, usually with Pact, and specification-based testing against an OpenAPI document. They're often treated as interchangeable. They aren't, and picking the wrong one for your situation leads to either false confidence or a lot of maintenance work.
What a contract test actually checks
A contract test answers one question: can this consumer and this provider talk to each other? It doesn't test business logic ("is the balance calculated correctly?"). It doesn't replace functional tests of the provider or end-to-end tests of key journeys. It checks the shape and semantics of the interface: paths, methods, status codes, headers and the fields a consumer depends on.
The value lies in timing. A contract test runs in each service's own pipeline, in seconds, without deploying the other service. A breaking change fails the pull request that introduced it, not a staging deployment three days later.
Consumer-driven contracts with Pact
With Pact, the consumer writes the contract as a side effect of its unit tests. The test describes the requests the consumer sends and the responses it expects. Pact runs a mock provider that serves exactly those responses, and records the interactions in a JSON pact file.
// npm i -D @pact-foundation/pact run: node --test (Node 20+)
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, integer, string, regex } = MatchersV3;
// The real client code under test (normally imported from src/).
class AccountsClient {
constructor(baseUrl) { this.baseUrl = baseUrl; }
async getAccount(id) {
const res = await fetch(`${this.baseUrl}/v1/accounts/${id}`, {
headers: { Accept: 'application/json' },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}
const provider = new PactV3({
consumer: 'web-app',
provider: 'accounts-api',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('accounts-api contract', () => {
it('returns an existing account', () => {
provider
.given('account 42 exists')
.uponReceiving('a request for account 42')
.withRequest({
method: 'GET',
path: '/v1/accounts/42',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': regex('application/json.*', 'application/json') },
// Only the fields this consumer actually reads, matched by type.
body: like({
id: integer(42),
currency: string('EUR'),
balance: regex('^-?\\d+\\.\\d{2}$', '100.00'),
}),
});
return provider.executeTest(async (mockServer) => {
const account = await new AccountsClient(mockServer.url).getAccount(42);
assert.equal(account.currency, 'EUR');
assert.match(account.balance, /^\d+\.\d{2}$/);
});
});
it('returns null for an unknown account', () => {
provider
.given('account 999 does not exist')
.uponReceiving('a request for a missing account')
.withRequest({ method: 'GET', path: '/v1/accounts/999', headers: { Accept: 'application/json' } })
.willRespondWith({ status: 404 });
return provider.executeTest(async (mockServer) => {
assert.equal(await new AccountsClient(mockServer.url).getAccount(999), null);
});
});
});Notice what the contract contains: only the three fields this consumer reads, matched by type and format rather than exact value. If the provider adds twenty new fields, this contract doesn't care. That property is what makes consumer-driven contracts precise. The provider learns exactly which parts of its API are in use and by whom.
The pact file is then published to a Pact Broker (open source, or hosted as PactFlow). The provider's pipeline fetches the relevant pacts and replays them against a real running instance of the provider:
// Runs in the provider's CI against a locally started instance of accounts-api.
const { Verifier } = require('@pact-foundation/pact');
const db = require('./test-helpers/db'); // your own seeding helpers
new Verifier({
provider: 'accounts-api',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
providerVersion: process.env.GIT_SHA,
providerVersionBranch: process.env.GIT_BRANCH,
publishVerificationResult: process.env.CI === 'true',
// Verify what is on consumers' main branches and what is actually deployed.
consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
enablePending: true,
stateHandlers: {
'account 42 exists': () => db.seedAccount({ id: 42, currency: 'EUR', balance: '100.00' }),
'account 999 does not exist': () => db.deleteAccount(999),
},
})
.verifyProvider()
.then(() => console.log('Pact verification complete'))
.catch((err) => { console.error(err); process.exit(1); });Three concepts make this work in practice:
- Provider states (
given('account 42 exists')) let the provider seed the data each interaction needs. Each state handler is a small piece of test setup on the provider side. - Consumer version selectors decide which pacts to verify.
mainBranchplusdeployedOrReleasedis the usual recommendation: what consumers are building now, and what is actually running. can-i-deployqueries the broker's matrix of verified versions before each deployment. If the version you're about to deploy hasn't been verified against what's in the target environment, the pipeline stops.
# Consumer pipeline: publish the generated pact, tagged with commit and branch
pact-broker publish ./pacts \
--consumer-app-version "$GIT_SHA" \
--branch "$GIT_BRANCH" \
--broker-base-url "$PACT_BROKER_BASE_URL" \
--broker-token "$PACT_BROKER_TOKEN"
# Any pipeline, before deploying: is this version compatible with what is in production?
pact-broker can-i-deploy \
--pacticipant web-app --version "$GIT_SHA" \
--to-environment production \
--broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"
# After a successful deployment: tell the broker what is now running where
pact-broker record-deployment \
--pacticipant web-app --version "$GIT_SHA" --environment production \
--broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"Specification-based testing with OpenAPI
The other approach treats the OpenAPI document as the contract. There is no per-consumer contract. Instead:
- The provider is tested for conformance to the spec. Tools like Schemathesis generate requests from the spec (property-based testing) and check that responses match the documented schemas and status codes. Simpler setups validate real responses in existing integration tests against the spec, for example with middleware validators.
- Consumers develop against mocks generated from the same spec (for example, Prism), or generate typed clients from it.
- Changes to the spec are checked for backward compatibility in pull requests with a diff tool such as oasdiff, which classifies changes like removing a field, adding a required parameter or narrowing an enum as breaking.
This works without consumer cooperation, which is exactly why it is the standard for public and partner APIs. You can't ask your customers to write Pact tests, but you can promise that the published spec is accurate and changes compatibly.
The key difference: what the contract is based on
Pact contracts are built from what consumers actually use. OpenAPI validation is built from what the provider says it offers. That leads to different failure modes:
- OpenAPI validation can't tell you whether removing a field breaks anyone. The spec says the field is optional, so removing it from responses is "compatible", but a consumer that relies on it breaks anyway. Pact would flag it, because the consumer's pact includes the field.
- Pact can't tell you whether your API matches its documentation. If no consumer with Pact tests calls an endpoint, that endpoint is not covered at all.
- Both depend on accuracy. A Pact test with an overly loose matcher, or a spec with
additionalProperties: trueand no required fields, passes everything and protects nothing.
A useful rule of thumb: Pact checks that a specific pair of services can talk. OpenAPI checks that the provider keeps its public promise.
When each approach fits
Choose Pact (consumer-driven) when:
- Both sides are inside your organisation, and teams can coordinate through a broker.
- You have many internal consumers and want to know exactly who uses what before changing or removing something.
- You deploy services independently and want
can-i-deployas a safety gate instead of a shared staging environment. - Interactions include message queues: Pact also supports asynchronous message contracts, where OpenAPI doesn't apply (AsyncAPI is the spec equivalent).
Choose OpenAPI validation when:
- The API is public or used by partners you don't control.
- The spec is already the source of truth (design-first, generated SDKs, published docs).
- Consumer teams don't have the capacity or willingness to maintain Pact tests. A Pact setup with half the consumers missing gives a dangerous sense of coverage.
Use both when you have an internal service mesh and a public API: Pact between internal services, OpenAPI conformance and diff checks at the public edge. PactFlow's bi-directional contract testing is a middle ground. Consumers publish pacts (or mocks generated from other tools), the provider publishes its OpenAPI spec plus evidence that it conforms, and the broker compares the two statically. It needs less work from consumers, but it's only as reliable as the provider's conformance testing.
A CI setup that blocks breaking changes
For a provider with a published OpenAPI spec, two checks on every pull request catch most problems: a diff of the spec against main, and a conformance run against the actual implementation.
# .github/workflows/api-contract.yml (provider repository)
name: api-contract
on: [pull_request]
jobs:
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 1. Breaking-change detection: compare the PR's spec with main
- name: Detect breaking changes
run: |
git show origin/main:openapi.yaml > base.yaml
docker run --rm -v "$PWD:/specs" tufin/oasdiff \
breaking /specs/base.yaml /specs/openapi.yaml --fail-on ERR
# 2. Does the implementation actually conform to the spec?
- name: Start API
run: docker compose up -d --wait api
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Property-based conformance tests
run: |
pip install schemathesis
schemathesis run openapi.yaml --url http://localhost:8080 --checks allPoints that matter in this pipeline:
- Diff against the base branch, not the previous commit. A breaking change split over several commits is still a breaking change.
- Allow intentional breaks explicitly. Sometimes you do need to break the API, for example with a new major version. Make that a visible decision (a label, a version bump, an exceptions file) rather than editing the pipeline.
- Run conformance against a real instance with realistic data. Schemathesis finds a surprising number of 500 errors from inputs that are valid per the spec but unhandled by the code. Those are worth fixing even when they aren't contract issues.
- Schemathesis CLI flags changed between major versions (for example
--base-urlin 3.x became--urlin 4.x). Pin the version in CI.
For Pact, the pipeline shape is: consumer CI runs the Pact tests and publishes; the broker triggers provider verification through a webhook when a pact changes; both sides run can-i-deploy before deploying and record-deployment after. Enable pending pacts so a new consumer expectation doesn't break the provider's main build before the provider has had a chance to implement it.
Evolving an API without breaking consumers
Contract tests tell you when a change is breaking. You still need a way to make necessary changes safely. The pattern that works with both approaches is expand and contract:
- Expand: add the new field, endpoint or parameter alongside the old one. This is a compatible change, and both oasdiff and existing pacts pass.
- Migrate: consumers move to the new field. With Pact, the broker shows which consumer versions still use the old field. With a public API, you rely on usage logs and a published deprecation notice. The
DeprecationandSunsetHTTP response headers (RFC 9745 and RFC 8594) let clients detect this programmatically. - Contract: remove the old field only once no verified consumer version depends on it.
can-i-deployturns that "only once" into an automated check rather than a guess.
This is slower than a single breaking change. It is also the only way to change an interface used by independently deployed services without a coordinated release.
Common pitfalls
- Testing the provider's logic in Pact. Contracts that assert exact balances or long lists of values become brittle and duplicate functional tests. Match by type and format, and assert only what the consumer genuinely needs.
- Too many provider states. Every state is setup code the provider maintains. Keep them few and reusable.
- A spec nobody validates. Hand-written OpenAPI documents drift. If nothing in CI checks the implementation against the spec, the spec is documentation, not a contract.
- Ignoring error responses. Contracts often cover only 200 responses. Consumers break just as badly when a 404 becomes a 400 or the error body changes shape.
- Skipping
record-deployment. Without it, the broker doesn't know what is in production, andcan-i-deployanswers the wrong question.
Where to start
If you have nothing yet, start with the provider side: make the OpenAPI spec accurate, add oasdiff to pull requests and run Schemathesis against staging. That protects every consumer at once and usually turns up real bugs in the first run. Add Pact for the internal service pairs where breaking changes have actually caused incidents. Measure success by the number of integration bugs that reach staging, not by the number of contracts written.
Want a second pair of eyes on your system?
Request the free 20-minute check-up: short written findings, non-intrusive, no obligation.