Testing the Contract, Not Just the Code
Unit tests prove the handler works; contract tests prove the promises hold; compatibility tests prove yesterday's consumers survive tomorrow's deploy. An API test suite is organized around the guarantees, and the cheapest test that catches each broken guarantee wins.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The layers, priced by what they catch
API testing is a portfolio, and each layer earns its place by the failures it catches per unit of cost. Unit tests exercise handler logic fast and in isolation — necessary, and silent about the contract (a handler can be perfectly unit-tested while returning a shape no consumer expects). Integration tests run the real HTTP stack against real serialization and (ideally) a real database, catching the gap between "the function returns the object" and "the wire carries the JSON". Contract tests verify recorded consumer expectations against the running provider. Compatibility tests diff today's schema against the shipped one. End-to-end tests thread whole consumer journeys and are reserved for the few flows worth their flake. Load tests check that the promises hold at production shapes — pagination at page 4,000, the rate limiter at the limit.
The ordering principle is cheapest-catcher-wins: a field rename should die in a schema diff (milliseconds, exact blame), not in an e2e suite (minutes, vague blame), and never in a consumer's production (days, your reputation). Most teams over-invest at the ends of the pyramid — thousands of unit tests, a flaky e2e suite — and leave the middle empty, which is exactly where contract breakage lives.
| Layer | Uniquely catches | Cost profile | Blind spot |
|---|---|---|---|
| Unit | Handler logic, edge-case branches | ms; runs on every save | Everything about the wire and the contract |
| Integration | Serialization, status codes, headers, middleware, real queries | seconds; per PR | What consumers actually depend on |
| Contract (consumer-driven) | Provider drifting from recorded consumer expectations | seconds; per PR, per consumer | Consumers that never wrote expectations |
| Compatibility (schema diff) | Breaking changes vs the shipped spec: removals, renames, type changes, optional→required | ms; per PR | Behavioral breaks with unchanged shapes |
| End-to-end | Cross-service journeys, auth flows, config wiring | minutes; per deploy; flaky | Too slow and coarse to guard every clause |
| Load / negative | Promises under production shapes: limits, deep pages, malformed input, giant bodies | scheduled | Novel inputs you did not think to send |
Contract tests: the consumer's expectations, executable
A contract test inverts the usual direction of testing. The *consumer* records what it actually depends on — "when I GET /orders/o1, I need id, status, and total.amount as an integer" — and the *provider* runs those recorded expectations against its real service in CI. Now the provider's build fails when it breaks the checkout team, before deploy, with the consumer's name on the failure. This is Consumer-Driven Evolution: Telemetry Before Breakage made mechanical: the consumer registry stops being a YAML file and becomes an executable veto.
Two disciplines keep contract tests honest. Consumers must record only what they *use* — an expectation that pins the entire response body re-freezes the API just like Hyrum's Law would, and turns every additive change into a false alarm; good contract tests assert presence and type of needed fields and stay silent about the rest (which is also the forward-compatible posture Backward Compatibility: The Real Rules asks of clients). Providers must run the expectations of *every registered consumer*, not just the loudest — the value of the mechanism is precisely the consumer you forgot about.
Schema-level compatibility checks are the cheap sibling: diff the current OpenAPI/protobuf/GraphQL schema against the released version and fail the build on the breaking-change list — removed field, renamed field, type change, new required parameter, narrowed enum (see OpenAPI: Describing the Contract, Not Designing It and Removing Fields Without Removing Consumers). They know nothing about behavior, but they catch the whole mechanical class of breaks for the price of a linter.
1# in the CHECKOUT team's repo — what checkout actually uses2expectation "loading a placed order":3 request: GET /orders/{id}4 response: 2005 id: present, string6 status: one_of [placed, paid, shipped, delivered, …] # tolerant: new values allowed7 total.amount: present, integer # cents — checkout does math on this8 # note: asserts nothing about the other 14 fields9 10# in the PROVIDER's CI — runs every registered consumer's expectations11verify(consumers = [checkout, mobile-bff, partner-gateway])12→ FAIL: checkout / "loading a placed order"13 total.amount: expected integer, got string "12.99"14 # the deploy that would have broken checkout dies here,15 # with the consumer's name in the failureNegative paths, fuzzing, and testing the promises under load
Half the contract is about what happens when things go wrong, and that half is the least tested. Negative tests send what the docs say is invalid and assert the *documented* failure: the right status, the right machine-readable code from your An Error Taxonomy Clients Can Branch On, the field-level detail Validation Errors: Feedback, Not Verdicts promises, Retry-After on 429s, request_id present in every error body. An API whose error paths are untested has an error contract that exists only in the documentation — consumers will discover the divergence in production, in their retry loops.
Fuzzing extends negative testing past your imagination: generate malformed JSON, wrong types, absurd sizes, negative numbers, unicode edge cases and unknown fields — cheaply derivable from the schema — and assert one property: the API responds with a *controlled* 4xx, never a 500, a hang, or a stack trace in the body (a 500 on malformed input means unvalidated input reached your internals, which is a security finding as much as a bug — the input-validation boundary Validation Errors: Feedback, Not Verdicts describes, probed automatically). Reliability clauses deserve the same directness: replay a mutation with the same Idempotency Keys: The Mechanism value and assert one effect; send two conflicting updates and assert the Optimistic Concurrency: Versions and If-Match 409; walk pagination under concurrent writes and assert no skips or duplicates (Cursor Pagination: An Opaque Bookmark, Not a Position).
Finally, test at production *shapes*, not just production *rates*: page 4,000 of a deep collection, the tenant with 3,000 tasks, a batch of exactly the maximum size, the response at the payload cap. Load testing that replays median traffic validates the median — your incidents live in the p99 shapes (see Payload Size: 20KB, 200KB, 5MB and Unbounded Collections: The Anti-Pattern With a Fuse).
- Test the error contract explicitly — status, error code, field details,
Retry-After,request_id: each documented failure is an assertable promise. - Fuzz from the schema — malformed and hostile input must produce controlled 4xx, never 500s or hangs; a fuzzer-found 500 is a validation gap.
- Test the reliability clauses — idempotent replay, concurrent-update conflict, pagination stability under writes: the promises consumers build retry loops on.
- Load-test the p99 shapes — deep pages, giant tenants, maximum batches; median-traffic replays validate nothing that fails.
- Compatibility gates in CI — schema diff plus consumer expectations; a breaking change should require overriding a red build, not noticing a subtle diff.
Key points
- Organize the suite by promise, not by module: every contract clause gets a test at the cheapest layer that can catch its violation.
- Unit and e2e tests skip the middle layers where contract breakage actually lives — integration, contract and compatibility tests are the API-specific work.
- Consumer-driven contract tests reverse the direction: consumers record what they use, the provider's CI verifies it, and breaking a consumer requires ignoring a named red build.
- Contract expectations must be tolerant (assert what you use, allow the rest) or they re-freeze the API against additive change.
- Error paths, fuzz inputs and reliability clauses (idempotency, concurrency, pagination stability) are contract surface — untested, they exist only in the docs.
- Test production shapes (deep pages, giant tenants, max batches), not just production rates.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → suite: writes thorough unit tests and a smoke e2e; every build is green and everyone is confident.
- 2Team → refactor: a serializer change turns
total.amountfrom integer cents into a decimal string; no unit test looks at the wire, the e2e never reads the field. - 3CI → deploy: green build ships on Tuesday afternoon.
- 4Consumers → production: checkout's price math silently corrupts; the partner's strongly-typed SDK throws on deserialization; two incidents open within an hour.
- 5Team → postmortem: the action item is "add a test" — the missing layer was never one test, it was the contract and compatibility gates that make this class of change undeployable.
- Consumers become the test suite: every gap in the provider's middle layers is discovered as someone else's production incident.
- Deploy confidence collapses after the first silent break — releases slow down, changes batch up, and each bigger batch is riskier than the last.
- Untested error paths corrupt consumer retry logic: a 500 where the contract promised 429, and a client retries a non-retryable failure into an outage (see Retryability: Telling Clients What To Do Next).
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Gate CI on schema compatibility (diff against the released spec, fail on the breaking-change list) — the cheapest test with the highest catch rate.
- • Adopt consumer-driven contract tests for every registered internal consumer, and make adding expectations part of integrating with the API.
- • Write negative tests for every documented error and fuzz the input boundary from the schema; assert controlled failures, never 500s.
- • Schedule load tests against p99 data shapes, and treat "works at page 1" as untested for a promise that includes page 4,000.
- • Contract-clause coverage: which documented promises (errors, idempotency, pagination, limits) have named tests — the gaps are your next incident list.
- • Production 500-rate on 4xx-class inputs (malformed bodies, bad params): a nonzero rate means the fuzz suite has real work left.
- • Escaped-defect ratio: consumer-reported breaks that CI could have caught, per quarter — the metric that justifies (or indicts) the suite's shape.
- • Contract and compatibility gates are what make additive evolution fast: a green consumer-expectation run is permission to deploy, replacing meetings with mechanics.
- • When a break is genuinely intended, the failing expectations become the migration worklist — each named consumer either updates its expectation or blocks the change, which is [[api-migration]] with receipts.
- • Retire expectations with their consumers: a contract test for a decommissioned client is friction defending nobody.
- • Contract-test infrastructure (expectation exchange, broker, provider verification) is real setup and ongoing coordination between teams that unit tests never need.
- • Tolerant expectations by design miss some real breaks (a semantic change behind an unchanged shape) — behavioral compatibility still needs integration tests and honest changelogs.
- • Fuzz and load suites are slow and occasionally flaky; run them scheduled rather than per-PR, and accept the window that leaves open between runs.