Contract Testing: Verifying API Shape Without Running Both Services
Understand what an API contract is, how consumer-driven contract testing (Pact-style) works between a consumer and a provider, how OpenAPI schema validation checks responses against a spec, backward compatibility checking, provider verification, how API versioning interacts with contracts, and how contract tests differ fundamentally from ordinary integration tests.
In a microservices architecture, Service A calling Service B's API creates an implicit agreement about the shape of that interaction — what A sends, what B returns, which fields are required, what a given status code means. A full integration test for this proves the agreement holds by actually running both services together and making a real call, which is thorough but expensive: it needs both codebases checked out, both services booted, a network path between them, and it breaks the moment either team's local environment drifts from the other's expectations. Contract testing verifies the same agreement without ever running both services together — it captures the SHAPE of the interaction as an explicit, machine-checkable artifact (the 'contract') and checks each side against that artifact independently, which is what lets two teams verify compatibility without either one needing the other's service running at all.
What an API contract actually is
A contract is a concrete, serialized description of one specific interaction between a consumer (the service making a call) and a provider (the service handling it): the request that will be sent (method, path, headers, body shape) and the response that's expected back (status code, headers, body shape — often with example values standing in for 'a string matching this pattern' or 'an integer', not literal values that must match exactly). It's narrower than a full API specification in one important sense and broader in another: narrower because it only describes the specific interactions a specific consumer actually uses (not every endpoint the provider exposes), and it's a live, generated artifact — checked into a shared location or a broker service — rather than a document a human writes by hand and hopes stays accurate.
The critical property a contract has that a REST client's ad-hoc assumptions don't is that it's explicit and checkable by both sides independently: the consumer team can verify 'does my code correctly send and parse what this contract says' without the provider running, and the provider team can verify 'does my actual service produce responses matching this contract' without the consumer running — the contract is the one artifact both verifications check against, rather than each team's separate, possibly-divergent mental model of what the other expects.
Consumer-driven contract testing: the Pact pattern
Consumer-driven contract testing (the pattern popularized by the Pact framework, which has a PHP implementation) inverts who writes the contract compared to a hand-authored API spec: the CONSUMER writes it, because the consumer is the one who actually knows exactly which fields it reads and which interactions it makes — a provider guessing at a spec tends to either over-specify (documenting fields no consumer uses) or under-specify (missing something a real consumer needs). The workflow has four stages. First, the consumer team writes a 'consumer test' against a Pact mock provider — code that says 'when I send this request, I expect this shaped response back' — and running that test both verifies the consumer's own code handles the response correctly AND generates a contract file (a Pact JSON file) as a byproduct.
Second, that contract file is published to a shared location (commonly a Pact Broker, a small service that stores contracts and tracks which provider/consumer version pairs have been verified against each other). Third, the PROVIDER team runs 'provider verification': Pact replays every recorded request from the contract against the provider's REAL running service (this is the one place a real service actually runs — just one side, never both at once) and checks that the real responses match what the contract says the consumer expects. Fourth, the Pact Broker records the verification result, and a 'can-i-deploy' check queries it before either side deploys: has this exact consumer version's contract been successfully verified against this exact provider version? If not, deployment is blocked — this is what lets two independently-deployed services stay compatible without ever needing to run an end-to-end environment with both of them live simultaneously.
Schema validation: checking responses against an OpenAPI spec
A distinct but related technique is validating actual API responses against a formal OpenAPI (Swagger) specification — a YAML/JSON document describing every endpoint, its request/response shapes, required fields, and data types for the WHOLE API, written and maintained independently of any specific consumer. Where a Pact-style contract is consumer-specific and generated from real usage, an OpenAPI spec is provider-wide and typically hand-maintained (or generated from route/type annotations in the provider's own code).
Schema validation testing takes a real (or recorded) API response and checks it structurally against the relevant portion of the OpenAPI document — right status code, response body matches the declared schema, required fields present, types correct — using a schema-validation library (league/openapi-psr7-validator is a common PHP choice, validating actual PSR-7 request/response objects against an OpenAPI document). This catches a provider silently drifting from its own published spec (a field the spec calls required stops being returned, a type changes from string to int) even when no specific consumer-side contract exists to catch it — it's a self-consistency check on the provider, complementary to consumer-driven contracts rather than a replacement for them.
Backward compatibility checking and provider verification
Backward compatibility checking asks a narrower, more mechanical question than full contract verification: does a NEW version of the API spec/schema still satisfy every consumer contract (or every constraint) the OLD version did? Tools that diff two OpenAPI documents (or two versions of a schema) can flag specific breaking changes automatically — a field that was required and is now removed, a field whose type narrowed incompatibly (int to a specific enum of ints), a response status code that's no longer returned where a consumer's contract depends on it. This is valuable because it catches an entire class of 'technically the provider tests still pass, but this change would break a real consumer' problem before it ships, by comparing spec-to-spec rather than waiting for a consumer's contract verification to fail.
Provider verification (the Pact-side term introduced above) is the concrete mechanism that catches this in practice for consumer-driven contracts specifically: every time the provider's code changes, CI re-runs provider verification against every currently-published contract from every consumer, replaying each contract's recorded requests against the real running provider. A provider team can see, before deploying, exactly which consumers (if any) would break from a given change — not by guessing, but by literally replaying real consumer expectations against the new code.
API versioning in the context of contracts
Contracts interact with API versioning in two concrete ways. First, a versioned API (/v1/orders vs /v2/orders, or a version header) effectively multiplies the set of contracts that need verifying — each version a provider still supports needs its own set of provider-verification runs against whichever consumers still target that version, and a provider considering deprecating a version can check, via the Pact Broker's consumer records, exactly which consumers are still on it before removing it, rather than guessing from documentation or tribal knowledge.
Second, contract testing changes how a team thinks about what counts as a 'breaking' change worth bumping the version for: a change that no currently-published consumer contract depends on (an unused field removed, a new optional field added) is provably non-breaking against every real consumer today, and doesn't need a version bump; a change that fails even one consumer's provider verification is provably breaking for that consumer specifically, whether or not it would be breaking 'in general.' This turns versioning decisions from a judgment call based on reading a diff into an evidence-based check against actual, currently-deployed consumer expectations.
How contract tests differ from ordinary integration tests
An ordinary integration test between two services boots (or connects to a real, running instance of) both services and makes an actual call across the network, asserting on the real end-to-end behavior — it proves the two systems work correctly together, right now, in this exact environment, but it requires both to be available, tends to be slow, and is often flaky for reasons that have nothing to do with the actual interaction being tested (a shared test environment's database state, network timing, an unrelated service being down). A contract test never runs both services at once: the consumer side runs against a mock generated from the contract (no real provider needed), and the provider side runs against the real provider but replays only recorded requests (no real consumer needed) — each side is tested in isolation against the shared contract artifact, not against each other directly.
The tradeoff this represents is deliberate, not a shortcut: a contract test proves the SHAPE of the interaction is compatible on both sides independently — the consumer correctly sends and parses what the contract describes, and the provider correctly produces what the contract describes — but it does NOT prove full end-to-end business-logic correctness the way a true integration or end-to-end test would (e.g. it won't catch 'both sides handle the shape correctly, but the actual business outcome across both services together is wrong'). In practice the two are complementary and most mature systems use both: many fast, contract-level tests run on every commit to catch shape drift immediately and cheaply, and a much smaller number of slower, real end-to-end tests run less frequently to catch the deeper class of bug contract tests are explicitly not designed to catch.
• Integration test: both services really running, real network call, proves true end-to-end behavior right now • Contract test: neither service needs the other running; each side checked independently against a shared shape artifact • Contract tests are fast and can run on every commit; full integration/E2E tests are typically slower and less frequent • Contract tests catch shape drift early; they do not replace end-to-end verification of actual business outcomes
A minimal Pact PHP consumer test and the contract it produces
given(...) names a 'provider state' — a precondition the provider must be able to set up (e.g. seed order 42 in a shipped state) during provider verification so the replayed request has something real to match against. The generated pact JSON file is the actual contract artifact; it gets published to a Pact Broker for the provider team's verification step to consume.
use PhpPact\Consumer\InteractionBuilder;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Standalone\MockService\MockServerConfigInterface;
$builder = new InteractionBuilder($mockServerConfig);
$request = (new ConsumerRequest())
->setMethod('GET')
->setPath('/orders/42');
$response = (new ProviderResponse())
->setStatus(200)
->addHeader('Content-Type', 'application/json')
->setBody(['id' => 42, 'status' => 'shipped', 'total' => 19.99]);
$builder
->given('order 42 exists and has shipped')
->uponReceiving('a request for order 42')
->with($request)
->willRespondWith($response);
// Running the actual consumer code against $mockServerConfig's base URL here
// both verifies the consumer parses the response correctly AND, on success,
// causes Pact to write out a pact JSON file describing this interaction.Code Examples
This test never involves the consumer's actual code at all — it replays the recorded requests from every published contract against the real, running OrdersService and diffs the real responses against what each contract expects. A failure here means a specific, identified consumer (whichever contract failed) would break against this exact provider code, discovered without ever needing that consumer's service running anywhere. The Pact Broker also receives this verification result and version-tags it, which is what powers a later can-i-deploy check before either side ships to production.
$verifier->verify(); // fails the CI step if any contract interaction mismatches
This runs against the provider's own real responses (typically inside the provider's own integration test suite) and checks structural conformance to the OpenAPI spec directly — no consumer or contract broker involved at all. It catches the provider silently drifting from its own published API documentation (a required field quietly stops being returned, a status code the spec never declared starts appearing), which a consumer-driven Pact contract only catches if some consumer's contract happens to exercise that exact field. Expected result: A ValidationFailed exception naming the exact schema violation (missing required field, wrong type, undeclared status code) when the real response doesn't match openapi.yaml.
<?php
declare(strict_types=1);
use PhpPact\Verifier\Verifier;
use PhpPact\Verifier\Model\VerifierConfig;
use PhpPact\Verifier\Model\PactUrlSource;
$config = (new VerifierConfig())
->setProviderName('OrdersService')
->setProviderVersion(getenv('CI_COMMIT_SHA'));
$verifier = new Verifier($config);
// Point Pact at the REAL provider, running locally in CI against a
// seeded test database (not a mock — this is the one real service).
$verifier->providerBaseUrl('http://localhost:8080');
// Pull every published contract this provider needs to satisfy,
// e.g. from a Pact Broker, and replay each recorded request against
// the real, running provider above, checking the real response
// against what each contract says the consumer expects.
$verifier->addSource(
new PactUrlSource('https://pact-broker.example.com/pacts/provider/OrdersService/latest')
);
$verifier->verify(); // fails the CI step if any contract interaction mismatches<?php
declare(strict_types=1);
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
$validator = (new ValidatorBuilder())
->fromYamlFile(__DIR__ . '/openapi.yaml')
->getResponseValidator();
function assertResponseMatchesSpec(
\League\OpenAPIValidation\PSR7\ResponseValidator $validator,
\Psr\Http\Message\ServerRequestInterface $request,
\Psr\Http\Message\ResponseInterface $response
): void {
try {
$validator->validate(
(new \League\OpenAPIValidation\PSR7\OperationAddress(
(string) $request->getUri()->getPath(),
strtolower($request->getMethod())
)),
$response
);
} catch (ValidationFailed $e) {
throw new \RuntimeException(
"Response for {$request->getMethod()} {$request->getUri()} " .
"does not match the OpenAPI schema: {$e->getMessage()}"
);
}
}Consumer-driven contract flow: neither side ever runs both services
Each side is checked independently against the shared contract artifact — a full end-to-end run with both services live is never required for this verification.
CONSUMER side PROVIDER side
-------------- -------------
Consumer test runs against
a Pact MOCK provider
(no real provider needed)
|
v
generates pact.json ----publish to Broker----> fetched by provider CI
(the contract) |
v
Verifier replays every recorded
request from pact.json against
the REAL running provider
(no real consumer needed)
|
v
verification result recorded
back to the Broker
Before deploy: "can-i-deploy" query checks the Broker --
has THIS consumer version's contract been verified against
THIS provider version? Blocks deploy if not.Why contract testing exists: the N-services integration-test explosion
In an architecture with a dozen microservices, spinning up every combination of services for full integration testing before every deploy becomes combinatorially expensive and operationally fragile — shared staging environments become a bottleneck, and flaky failures unrelated to the actual code change under test erode trust in the whole test suite. Contract testing was developed specifically to let each team deploy independently with confidence: a provider team can verify compatibility against every consumer without booting a single one of those consumers' services, and a consumer team can verify their own code against every interaction shape they depend on without booting the provider — turning an N-times-M full-environment problem into two much smaller, independent, fast-running checks.
// Provider team hand-writes an OpenAPI spec once, publishes it, and // assumes 'consumers read the docs' is sufficient compatibility checking // — no automated verification that the real API still matches it.
// Provider team runs schema validation against real responses in CI // (catches provider drift from its own spec) PLUS runs Pact provider // verification against every consumer's published contract (catches // breakage specific to what real consumers actually depend on).
A hand-maintained spec nobody automatically checks against real responses drifts silently — the documentation and the actual API diverge over time and nobody notices until a consumer breaks in production. Automated schema validation catches self-drift; consumer-driven contract verification catches the more specific and more urgent case of a real, currently-deployed consumer actually breaking.
In consumer-driven contract testing (Pact-style), who writes the contract, and why? A. The provider writes it, since only the provider knows its full API surface B. The consumer writes it, because the consumer is the one who knows exactly which fields and interactions it actually depends on, avoiding a provider guessing at over- or under-specified documentation C. A third-party auditor writes it to remain neutral D. Contracts are always auto-generated from the provider's database schema
B. The consumer writes it, because the consumer is the one who knows exactly which fields and interactions it actually depends on, avoiding a provider guessing at over- or under-specified documentation — The consumer writes the contract because it's the side that knows precisely what it reads and expects from a given interaction; letting the provider guess at a spec risks documenting fields no consumer uses or missing ones a real consumer needs — consumer-driven contracts are generated directly from real consumer usage instead.
What is the fundamental difference between a contract test and an ordinary integration test? A. Contract tests are always slower because they require a real network call between two live services B. A contract test verifies the shape of an interaction on each side independently against a shared contract artifact, without ever running both services together, while an ordinary integration test runs both real services and makes an actual call between them C. Integration tests cannot check API response structure, only contract tests can D. There is no real difference; 'contract test' is just another name for an integration test
B. A contract test verifies the shape of an interaction on each side independently against a shared contract artifact, without ever running both services together, while an ordinary integration test runs both real services and makes an actual call between them — A contract test checks the consumer against a generated mock and the provider against replayed recorded requests — neither side needs the other actually running. An integration test proves true end-to-end behavior by running both real services together, which is more thorough for business-logic correctness but far more expensive to set up and run.
What does provider verification concretely do in the Pact workflow? A. It generates the OpenAPI specification automatically from the provider's route definitions B. It replays every recorded request from a published consumer contract against the real, running provider, and checks the real responses match what the contract expects C. It runs the consumer's test suite against a mock of the provider D. It measures the provider's response time under load
B. It replays every recorded request from a published consumer contract against the real, running provider, and checks the real responses match what the contract expects — Provider verification is the one step where a real service actually runs (the provider) — it takes every recorded request from a published contract and replays it against that real, running provider, failing if the real response doesn't match what the contract says the consumer expects.
A UserService (provider) is used by both a BillingService and a NotificationService (consumers). BillingService's contract expects GET /users/{id} to always include a billingEmail field; NotificationService's contract does not reference that field at all. UserService wants to rename billingEmail to invoiceEmail. Walk through, step by step, how consumer-driven contract testing surfaces whether this change is safe to ship, and for which consumer(s). Hint: Provider verification replays BOTH consumers' contracts against the changed code. BillingService's contract interaction that references billingEmail will fail verification (the field it expects is gone); NotificationService's contract, which never referenced that field, will still pass — so the tooling tells you precisely and automatically which consumer breaks, without needing either consumer's service running.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.