AI coding

Contract Tests Between Your App and an Inference API

Antonio Evans10 min read

A two-panel scene: on the left, a box labeled Your App sends a request to a cloud labeled Inference API; on the right, a clipboard with checkmarks inspects the JSON shape, one green check on a field.
Key points

Key points

  1. 1Contract tests at the model boundary should assert on payload shape, error envelopes, timeouts, and streaming chunk formats, not on the meaning of the generated text.
  2. 2Capture real request and response pairs once, store them as fixtures, and replay them against a local stub on every commit.
  3. 3Run the same assertions against the live provider on a schedule so provider drift surfaces before your users find it.
  4. 4Keep the provider behind a thin adapter and treat only that adapter as under contract, so the rest of the app keeps a stable interface.

Why the model boundary is your seam

Michael Feathers defines legacy code bluntly as code without tests, and his rule of thumb is that behavior is the thing users depend on: add it and they are happy, change it by accident and they stop trusting you (BSSw). A model API dependency has the same property. Nobody signs up for your request builder or your chunk parser, but the moment a provider renames a field or changes the error body, the wrapper you built becomes legacy code in the Feathers sense: untested behavior that other code trusts.

The useful image here is a seam. In Fowler's account of the Strangler Fig, teams move behavior out of an old system piece by piece, and that only works because they first find the seams where a component can be split off (martinfowler.com). Microsoft's architecture guide describes the same mechanics with a facade in front of the old backend that routes traffic while components migrate (Microsoft Learn). Applied to an inference dependency, the facade is the seam: one small module that owns the request shape and the response shape, and nothing else.

Branch by Abstraction gives you the operational version of that idea. You introduce an abstraction over the part you need to change, move callers onto it, and can then add a new implementation underneath while the rest of the code keeps compiling and shipping (continuousdelivery.com). Harness summarizes the same five steps and notes the payoff: both implementations coexist, so you can switch between them without a merge event (Harness). That is exactly the property you want when a provider announces a deprecation or when you begin dating a second provider.

Contract testing is what makes the seam trustworthy. An API contract is the formal agreement covering endpoints, methods, request bodies, response bodies, status codes, headers and error representation, and contract testing verifies on every change that both sides still honor it (Total Shift Left). The stats in that same write-up are worth internalizing: the World Quality Report cited there found 71% of organizations run more than 100 internal APIs, and DORA data cited by the same author puts elite performers at 2.6 times more likely to practice systematic contract testing than low performers (Total Shift Left). Whether or not you trust the exact figures, the direction is not controversial.

The decision rule at this point in the article is simple. If you call one model provider from one place, a thin adapter plus a handful of shape assertions costs an afternoon and buys you every future provider swap. If you call three providers from five places with no adapter, contract tests are not your first move; extracting the adapter is.

What actually goes in a contract at this boundary

The trap is confusing a contract test with an output test. Contract tests are deliberately narrow: they do not check whether the order was actually created or whether the summary is accurate, only whether the interface structurally conforms (Total Shift Left). At a model boundary, that narrowness is a feature, because output text is nondeterministic by design and asserting on it produces a flaky suite nobody reads.

So the contract is a checklist, not a document. Six things belong in it.

SurfaceWhat you assertWhy it breaks in practice
Request shapeRequired keys present, types correct, no unexpected top level keys sentClient libraries serialize defaults that a provider later rejects
Response envelopeEnvelope keys exist and are the right type, expected fields non nullFields get renamed or nested one level deeper
Token accountingUsage fields present and numeric where the provider promises themBilling and quota logic reads fields that silently become strings
Error shapeStatus code and body keys match a recorded failure fixtureError bodies move from a string to a nested object
TimeoutClient raises your error type, not a raw socket error, within a bounded windowA network layer change surfaces as a different exception class
Streaming formatEach chunk parses, carries the expected type markers, and the terminator is recognizedChunk framing changes and reassembly silently drops the tail

Schema-based approaches are the cheapest way to encode most of that table. Matt Fellows describes three common strategies and the trade-offs between them: generating a schema from code and diffing it, and code-based schema tests that invoke your real handler inside your unit test framework and check the output against a shared schema (PactFlow). His code-based example is worth copying almost verbatim: call the route function directly without starting a server, then assert the response matches the schema and the status code matches the expectation (PactFlow). That is milliseconds per test, deterministic, and dependency free.

One caveat on written specifications. A community thread on OpenAPI 3.0 contract testing shows practitioners wrestling with how to point a test run at a schema and validate a real response against it, and the discussion is instructive precisely because it shows the specification file and the runtime response drifting apart unless something checks them together (Postman Community). If your provider publishes a specification, treat it as a second contract to test, not as the test itself.

Capture real payloads as fixtures, once

Fixtures are the whole game. Hand-written sample payloads encode what you think the provider sends. Recorded payloads encode what it actually sent, including the fields you did not know about, the nulls where you expected strings, and the stray wrapper.

The capture loop has four steps.

  1. Turn on request and response logging in your adapter for one run in a non-production environment.
  2. Redact secrets (API keys, user identifiers, any raw text you cannot keep) before the file touches the repository.
  3. Save each pair as a named fixture next to the test that uses it, with a name that says what it represents: success, truncated stream, rate limited, invalid request.
  4. Assert against the fixture in the stub tests, and assert against the live provider only with the shape assertions from the previous section.

The reason redaction comes second and not fourth is that a committed fixture with a live credential is an incident, and rotation is slower than writing a scrub function. Keep fixtures in the repository as plain files, then load and parse them in the test. A minimal pattern looks like this:

import json, pathlib

FIXTURES = pathlib.Path(__file__).parent / "fixtures"

def load(name: str) -> dict:
    return json.loads((FIXTURES / f"{name}.json").read_text())

def test_success_response_shape():
    body = load("completion_success")
    assert isinstance(body["choices"], list) and body["choices"]
    first = body["choices"][0]
    assert isinstance(first["index"], int)
    assert first["message"]["role"] == "assistant"
    assert isinstance(first["finish_reason"], (str, type(None)))
    usage = body["usage"]
    assert all(isinstance(usage[k], int) for k in ("prompt_tokens", "completion_tokens"))

Notice what is absent: no assertion on the content string beyond its type. That is the difference between a contract test and a regression test on model quality, and it is the reason this suite stays green through a provider upgrade that changes wording but not shape.

The stub side of the loop is where mocking discipline matters. LangWatch's testing guide sorts mocks into levels, from tool function mocks up to dependency injection, and points out that mocking at the right boundary is what makes scenarios deterministic and offline friendly (LangWatch). The inference adapter is that boundary. Mock it, not the HTTP library underneath it, or you are testing your mock.

Error shapes, timeouts and streaming chunks

Three surfaces deserve their own section because they are the ones that break silently.

Error shapes. Record at least one failure fixture per class you care about: invalid request, authentication failure, rate limit, and server error. Then assert that your adapter converts each into your own error type with a stable code. The point is not the provider's message text, which will change, but the mapping. When your retry logic branches on an error code, that code is part of the contract, and it needs a test that fails loudly if the provider reshapes it.

Timeouts. A timeout test has two assertions: that the client gives up inside a bounded window, and that the failure surfaces as your error type rather than as a raw socket exception. Keep the window generous, since the test is about behavior, not speed. If your budget is a 30 second request timeout with two retries, the assertion should be that your adapter raises your Timeout error after its own retry policy has run, not that the underlying transport behaved in any particular way.

Streaming chunks. Here the fixture is a captured sequence rather than a single object, and the assertions are structural. Each line parses on its own. Each chunk carries a recognizable type marker. Exactly one chunk carries the terminal signal, and it is the last one. Reassembling all content deltas yields the same string the non-streaming call produced for the same fixture. That last assertion is a shape assertion in disguise and it catches the most common streaming bug, which is a dropped final delta.

A worked example makes the economics concrete. Suppose an adapter test suite has 40 tests. Thirty of them are fixture-driven stub tests at roughly 2 milliseconds each, six are error and timeout tests at 50 milliseconds each because they involve short sleeps, and four touch a local stub server at 30 milliseconds each. That is under a second for the whole suite, which means it runs on every commit without anyone complaining. The live provider run is a separate job with ten shape assertions and a cost budget, and it runs on a schedule and before every release, not on every push.

There is a second lever here that teams often miss. Because the adapter is the only thing under contract, you can run the same suite against a second provider. Fowler's account of incremental replacement is explicit that new features get built on top of, yet separate from, the old code, and behavior moves across bit by bit (martinfowler.com). If your shape tests are written against your own domain types rather than a provider's JSON, adding a candidate provider means writing a new adapter and reusing the suite. If they are written against provider JSON, you will rewrite the tests. The AWS guidance on the same pattern frames the payoff as reduced transformation risk and less disruption, which is precisely what a shared suite across two adapters buys you (AWS Prescriptive Guidance).

One more decision rule. If a surface has never changed in your recorded history and is unlikely to, do not write a test for it. Contract suites rot when they assert on everything; they stay useful when every assertion corresponds to a failure you would actually want a red build for. Feathers' change algorithm is a good filter: identify the change point, find the test point where the behavior can be sensed, and test that (BSSw).

Run against a stub on commit and live on a schedule

Two jobs, two purposes.

The commit job runs the stub suite. It is fast, it needs no network, no key and no budget, and it fails for exactly one reason: somebody changed the adapter's shape handling without updating the fixtures or the assertions. That is a signal worth blocking a merge on.

The scheduled job runs the live shape assertions. It is slow, it costs money, and it fails for a different reason: the provider changed something. Because it is not on the critical path for every pull request, a flake in it does not train your team to ignore red builds. Treat its result as a maintenance ticket with a deadline, not an outage.

ConcernStub jobLive job
TriggerEvery commit and pull requestSchedule plus pre-release
Runtime budgetUnder a few secondsA minute or two
Network and credentialsNoneKey from CI secret store
Primary failure meaningYour code drifted from the contractThe provider drifted from the contract
BlockingYes, block the mergeNo, open a ticket
CostZeroBounded by a per-run token budget

Keep the live job's scope tight. Ten to fifteen shape assertions on real requests is enough to detect a renamed field, a changed error envelope, or a new chunk type. Anything more and you are paying to rediscover your own stub suite.

If your stack already calls several model kinds, the adapter is also where portability pays off. Pointing that adapter at one API that serves text, image, video, audio and embedding models, rather than wiring a new client per modality, keeps the number of contracts you maintain small and makes the live job's coverage meaningful, which is the practical argument for Quanta Inference.

Keeping the contract honest over time

A contract suite is a living artifact, and it decays in three predictable ways.

It drifts from the fixtures. Someone hand-edits a JSON fixture to make a failing test pass. Guard against this by regenerating fixtures with a script that hits the provider and writes the file, so the only legitimate edit is a rerun of the script, and the diff is reviewable.

It grows assertions nobody needs. Every quarter, delete assertions for surfaces you no longer read. The signal-to-noise ratio is the whole value of the suite.

It loses its red-build meaning. When the live job has been yellow for three weeks, the suite is dead. Assign an owner and a response window, even if the window is generous.

There is a useful habit from gradual replacement work: never add to the old path. In the branch by abstraction write-up, the discipline is that developers stop adding queries in the old style, and one way to enforce it is a build that fails when a new one appears, so the count can only go down (continuousdelivery.com). Translate that to this boundary: new code calls the adapter, never the provider client directly, and a lint rule or a simple grep in CI enforces it. The contract suite only protects code that goes through the contracted path.

If your team writes a lot of integration code with coding agents, the same discipline carries over: the agent should be extending the adapter and the fixture set, not reaching around them, which is a good case for installing a shared skill so agent-written changes land inside the seam instead of beside it, as described in Quanta Skills.

FAQ

FAQ

What is the difference between a contract test and an integration test at a model boundary?

An integration test exercises the whole path, including the provider's real behavior and your business logic. A contract test checks only that the interface structurally conforms to what was promised, which is why it runs in milliseconds and can run on every commit (Total Shift Left). You want both, but the contract test is the one that should block a merge.

Should I assert on the model's output text?

No. Output is nondeterministic by design, and asserting on it produces flaky tests. Assert on the envelope: key presence, types, finish reason, usage fields and chunk structure. Keep output quality checks in a separate evaluation suite with its own thresholds and its own schedule.

How often should the live provider run happen?

Often enough to catch a change before your users do, and rarely enough that it does not eat your budget or your team's attention. A weekly schedule plus a run before every release covers most teams. Record the run in a dashboard so an unnoticed yellow job becomes visible.

Do I need a published OpenAPI specification to do this?

No, and a specification alone is not a contract test. Fellows lays out code-based schema tests that invoke your handler directly and compare the output to a shared schema, which needs no specification document at all (PactFlow). If a specification exists, test your adapter against it as an additional check, not as a substitute for recorded fixtures.

How do I keep streaming tests from becoming flaky?

Capture one real chunk sequence as a fixture rather than calling the live provider inside the test, then assert structure on the fixture: each line parses, the type markers are present, the terminal chunk is last, and reassembly matches the expected string. That test is deterministic and fast. Live streaming assertions belong in the scheduled job, where a flake does not block anyone.

Sources

Sources

  1. Working Effectively with Legacy Code - Better Scientific Software (BSSw)
  2. Strangler Fig - Martin Fowler
  3. Strangler Fig Pattern - Azure Architecture Center | Microsoft Learn
  4. Make Large Scale Changes Incrementally with Branch By ...
  5. How to Branch by Abstraction with Feature Flags
  6. API Contract Testing: What It Is, How It Works and Tools
  7. Schema-based contract testing with JSON ... - Pact Flow
  8. Open API Schema 3.0 Contract Testing - Help Hub
  9. Mocking External APIs in Agent Tests, Scenario
  10. Strangler fig pattern - AWS Prescriptive Guidance
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles