Development

Stubbing Model APIs So CI Quickstarts Stay Deterministic

Antonio Evans9 min read

A clean technical diagram of a CI pipeline with a cassette of recorded JSON fixtures feeding a test runner, a small mock server returning canned responses, and one real API call to a cloud marked cheap model.
Key points

Key points

  1. 1Stub the model API in CI by default: recorded fixtures for contract checks, a local mock server for edge cases and error paths.
  2. 2Keep a small real-model suite on a nightly or pre-release schedule so provider drift is caught before it reaches users.
  3. 3Store fixtures as plain JSON keyed by a hash of the request, commit them with the code, and review changes in pull requests.
  4. 4A quickstart smoke test should prove the documented path works without keys, network access or per-run cost.

Why a quickstart smoke test should never depend on a live model

A smoke test answers one question: is this build stable enough to test further? It runs a handful of shallow checks on the most important paths, and if any fail, deeper testing stops (the Drizz guide to smoke testing in CI/CD). For a model API quickstart that path is narrow: the client reads a key from the environment, sends one request, parses a response, prints something. Everything else is a different, slower kind of test.

Live calls make that check unreliable in three ways. They cost money on every run. They fail when the provider has an outage, rate-limits you, or rotates a credential a contributor does not have. And they are nondeterministic: sampling, provider-side prompt changes and safety filters can all alter the bytes that come back, so an assertion on exact text breaks for reasons unrelated to your code. A test that fails for reasons outside the repository is a test people learn to ignore, which is the worst outcome for a quality gate.

There is a second argument that is easy to miss. The quickstart in your README is documentation, and documentation has to be true. The Write the Docs community describes a docs-as-code philosophy where documentation is written with the same tools as code, lives in version control, and passes through the same review and automated test workflows (Write the Docs). A smoke test that executes the README steps in CI turns "the quickstart is probably still right" into a build that goes red when it is not.

A public issue tracker shows what adoption looks like. A contributor asked for a smoke test that proves the README quickstart path works without real provider keys: start the server against a local mock, hit the health, models and completion endpoints, cover at least one error path, and keep the test self-contained (the Free-Way issue). That is a good specification for any model-backed quickstart, and it is the shape this article builds on.

Three ways to stand in for the model, and what each one proves

A recorded fixture is a saved request and response pair taken from a real call. You match an incoming request and return the recorded body. This proves your client parses the real response shape. It does not prove the server is reachable today, and it does not prove the provider still returns that shape unless you refresh recordings on a schedule.

A local mock server speaks the provider's wire format and returns responses you write yourself. This proves your integration layer handles streaming, tool calls, retries, malformed JSON, 429s and 500s, and it is the only one of the three that exercises error paths cheaply. CopilotKit's LLMock, for example, advertises authentic server-sent events in real provider formats, fixture routing by substring, regex or predicate, and tool call injection for agent workflows (the CopilotKit announcement). It proves nothing about the provider.

A cheap real model closes the loop. It proves credentials work, the endpoint URL is current, the SDK matches the provider, and the response shape has not drifted. It is the only approach that catches a provider-side breaking change, and the only one that can fail for a reason outside your control.

ApproachProvesCost per runBest placement
Recorded fixturesClient parses the real response shape; documented example still matchesZeroEvery pull request
Local mock serverStreaming, error paths, tool calls, retry logicZeroEvery pull request
Cheap real modelCredentials, endpoint, SDK and schema are currentSmall but nonzero, plus latencyNightly and pre-release only

The key point first search results often miss: the real-model suite is not a better version of the fixture suite. It tests a different property, namely that the outside world has not moved. Running it on every pull request gives you the worst of both, because you pay the flakiness and gain nothing the offline tests do not already give you.

Recording fixtures from real calls and storing them as reviewable JSON

Recording is a one-off, run by a developer with a real key, and its output is committed like any other test asset.

There are two ways to capture. The first is a proxy in front of the real API that forwards each request and writes the request and response to a file. Mokapi documents exactly this: during recording it forwards requests through to the real backend and appends each pair to a JSON file, then in replay swaps the forwarding handler for a matcher that returns the captured response without the backend being reachable (Mokapi's record and replay guide). The second is a thin wrapper in your own harness that logs the raw HTTP exchange whenever it runs against the real endpoint.

Three properties make fixtures durable. They must be plain JSON in the repository so they diff in review. They must be keyed by something stable, normally a hash of the normalized request. And they must exclude volatile fields, or normalize them at replay time, so a timestamp or request ID does not cause a spurious miss. Keploy's write-up describes automatic noise detection that excludes timestamps, UUIDs and session tokens from strict assertions and freezes the clock to the recording time (Keploy's record and replay guide). You do not need their instrumentation to borrow the idea; a normalization function inside your test code is enough.

import hashlib, json, pathlib

FIXTURES = pathlib.Path("tests/fixtures/model")

def key_for(request: dict) -> str:
    body = {k: v for k, v in request.items() if k not in ("request_id", "timestamp")}
    normalized = json.dumps(body, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(normalized.encode()).hexdigest()[:16]

def replay(request: dict) -> dict:
    path = FIXTURES / f"{key_for(request)}.json"
    if not path.exists():
        raise FileNotFoundError(
            f"No fixture for request. Re-record with a real key and commit {path.name}"
        )
    return json.loads(path.read_text())["response"]

The miss behavior matters more than the hash. A fixture suite that returns a generic 404 for an unrecognized request hides new call sites forever. One that fails with the instruction to re-record surfaces them in the pull request that introduced them, which is the moment someone can decide whether the new call is intended.

Running a local mock server for streaming, tool calls and error paths

Fixtures excel at the happy path and are awkward everywhere else. You cannot easily record a 429 with a specific retry-after value, a stream that drops mid-response, or a tool call the model never made because the prompt was ambiguous. Write those by hand.

The mock should be a real HTTP server on localhost, not an in-process stub, because a real server exercises the same transport and connection handling your client will use. The Free-Way issue is explicit: the mock server should start and stop cleanly inside the test script, and the whole run should avoid real provider credentials (the Free-Way issue). A server also lets other processes in the same CI job reach it, which matters when the quickstart spawns a CLI or a container.

ScenarioWhat it catchesTypical assertion
Valid completionClient parses the standard responseOutput matches the fixture text
Streamed responseClient handles server-sent events and partial chunksAssembled text equals expected
Tool call in responseAgent loop dispatches and resumes correctlyTool invoked once with expected arguments
Missing API keyMessage names the variable, exit code nonzeroHelpful message, no stack trace
429 with retry-afterRetry policy honors the header and backs offRequest count and elapsed time
Malformed JSON bodyParser error is caught and reportedClear message, no crash

Keep the list short. The three smoke test filter questions apply: if this fails, can the user do anything at all; does every user hit this path; can this break because of a build or deployment issue (Drizz). A missing key and a streamed response pass all three. An obscure tool-call edge case belongs in the regression suite.

One more benefit: the mock makes the quickstart's error messages testable. A quickstart that prints "401 Unauthorized" when the key is missing is a bad quickstart. Postman's guide to API documentation makes the point that error documentation listing each code with troubleshooting guidance is part of what good docs contain, and the same reasoning applies to your failure output (Postman). If the smoke test asserts on the wording of the missing-key message, that message will not rot.

Getting the pipeline right: speed, cost and drift detection

The core CI advice is unglamorous and applies directly. GitLab's CI best practices recommend keeping builds fast and simple, and cite Martin Fowler's ten-minute build guideline as achievable for most modern projects (GitLab). A stub-based quickstart smoke test runs in seconds. A real-model version runs in seconds too, until it does not, and then it costs someone half a morning.

RunTriggerApproachBlocking?Time budget
Quickstart smokeEvery pull requestFixtures plus mockYesUnder 30 seconds
Contract testEvery pull requestFixtures, strict schema checkYesUnder 30 seconds
Real provider checkNightly scheduleCheap real model, one requestNo, notifiesUnder 2 minutes
Full provider checkPre-release tagCheap real model, full scenario listYes for releaseMinutes

The real-model suite needs guardrails. Cap it at a handful of requests so a runaway retry loop cannot generate a surprise bill, and treat its budget as a fixed line item rather than something that scales with commit volume. Keep the key in the CI provider's secret store, never in the repository, and run it from a workflow that forks cannot trigger.

A fixture refresh belongs in the same plan. Pick a cadence, run the recorder against the real API, and open a pull request with the diff. Reviewing that diff is where you catch a field that quietly changed type or an example that no longer matches the README. Fern's guide notes that treating the API specification as the single source of truth prevents drift between implementation, SDKs and documentation, with generation wired into CI/CD (Fern's best practices guide). Refreshed fixtures are the model-API version of that discipline: a machine-readable record of what the outside world actually said, checked by a human when it changes.

A worked example with numbers

Suppose your quickstart repo gets 300 pull requests a month across a team of eight. Your real-model smoke test makes three calls per run, each with a median latency of 1.5 seconds.

Stubbed, the whole job is roughly 12 seconds: checkout, install dependencies, start the mock, run four scenarios, stop the server. Real-model, the same checks take about 5 seconds of provider latency in the good case and, based on typical provider behavior, between 1 and 3 percent of runs fail for reasons unrelated to the change: a 429, a timeout, a transient 5xx. At 300 runs that is roughly 3 to 9 red builds a month that someone must investigate. At 10 minutes each for investigation and re-run, that is 30 to 90 minutes of developer time per month burned on noise, plus API spend, plus slow erosion of trust in a red build.

Move those calls to a nightly job. You get 30 real-provider checks a month instead of 300, still more than enough to catch a schema change within a day, and the pull request path becomes deterministic. The nightly failure is a signal, not a blocker, so it goes to a channel someone watches rather than sitting in front of a merge.

The rule that falls out is simple: run the check that can only fail for external reasons on a schedule, and run the checks that can only fail for internal reasons on every commit.

Keeping the quickstart and the stubs in step

The failure mode to avoid is a fixture suite that passes while the README is wrong. This happens when the quickstart drifts, someone updates the example code, and nobody updates the recorded request.

The fix is to make the smoke test execute the README's own steps rather than a parallel copy. If the README shows a shell command, the test runs that command. UC Berkeley's documentation guide recommends a README containing a brief description, installation instructions and a short example or tutorial, which is exactly the surface a smoke test should cover (UC Berkeley Library). Stoplight's guide makes the related point that a reference, a guide and a concrete example are three distinct jobs, and the concrete example is the one people copy (Stoplight). If the copied example is wrong, everything downstream is wrong, so it is worth a test.

Two habits keep the suite honest. Make the fixture miss loud, as in the harness above, so a new call site fails in review. And when the nightly real-model run fails, treat it as a fixture refresh plus a README check, not a flaky job to re-run. The refresh opens a pull request, the diff shows what changed, and the reviewer asks whether the quickstart still reads correctly.

If your quickstart reaches several providers or model types, the number of wire formats to mock grows quickly, and maintaining a mock per provider is the kind of work that quietly stops happening. Routing everything through one OpenAI-compatible endpoint reduces the surface your stubs must cover, which is what Quanta Inference provides: text, image, video, audio and embedding models behind a single key.

FAQ

FAQ

Should the quickstart smoke test call the real model API?

No, not on every pull request. Stub it with recorded fixtures and a local mock so the test is fast, free and deterministic, and keep a small real-model run on a nightly schedule or on release tags.

Do recorded fixtures test anything real, or are they just theater?

They test real things: that your client parses the actual response shape, that your documented example still matches a real exchange, and that your code handles fields like usage counters or finish reasons. They do not test reachability or current provider behavior, which is why the nightly real-model run exists (GoReplay).

How often should I re-record fixtures?

On a fixed schedule, monthly is a reasonable default, and immediately after any nightly real-model failure. Each refresh should arrive as a pull request you can review, because the diff is where you see a field change type or an example stop matching the README. An empty diff is useful information too: the contract held.

What should the mock server cover that fixtures cannot?

Error paths and streaming. A 429 with a retry-after header, a malformed JSON body, a 500, a stream that drops midway, and an injected tool call are all things you write by hand rather than record (CopilotKit).

Where should fixtures and mock scenarios live in the repository?

Next to the tests that use them, in plain JSON that diffs in review, committed with the code. That is the docs-as-code argument applied to test assets: same version control, same review, same automated checks as the code they describe (Write the Docs). A hosted service for fixtures defeats the purpose, because no reviewer can see what changed.

Sources

Sources

  1. Smoke Testing in CI/CD Pipelines: What Actually Matters
  2. Docs as Code - Write the Docs
  3. test Add Quick Start smoke test with a local mock provider
  4. CopilotKit
  5. Record and Replay Real API Traffic for Testing and Offline ...
  6. Record and Replay Testing Guide
  7. API Documentation: How to Write, Examples & Best Practices | Postman
  8. Continuous integration best practices
  9. API documentation best practices guide Feb 2026 - Fern: Docs
  10. How to Write Good Documentation: Home - UC Berkeley Library Guides
  11. API Documentation Best Practices Guide - Stoplight.io
  12. Record and Replay Testing for HTTP APIs with GoReplay
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