Tests that hit a real model are slow, cost money, and return something different every time — so LLM test suites either don't get run or get mocked so heavily they test nothing. Here are the four approaches, the trade-off that actually matters (which layer you mock at), and a working record/replay setup.
Testing an AI feature means running a loop that calls a model, maybe executes a tool, feeds the result back, and calls the model again. Every run of that loop is slow, costs money, and returns something slightly different. So teams end up in one of two bad places: nobody runs the tests, or everybody mocks the model so heavily the test no longer resembles what ships. Neither catches the bug that wakes you up.
The way out isn't "mock the model" or "don't mock the model" — it's choosing *which layer* to mock at, because that decision quietly determines what your test actually exercises. Below are the four approaches, the trade-off that matters, and a working record/replay setup using our open-source @duskel/cassette. The one-line version: record the model's answers once, replay them offline in milliseconds, and let your own code keep running for real.
Three properties of a real model call make it hostile to a normal test suite, and they compound:
The failure isn't technical, it's behavioural: every one of these pushes the team toward running the tests less. A good testing setup for AI code is really a setup that makes the tests fast, free, and stable enough that people actually run them.
These are the options in practice, from naive to robust. Most teams cycle through the top three before landing on the fourth.
| Approach | Speed / cost | What it tests | The catch |
|---|---|---|---|
| Live calls in tests | Slow, paid, every run | The real thing, end to end | Flaky (non-deterministic), expensive, and unusable in CI without a key and a budget |
| Hardcoded mocks | Instant, free | Almost nothing — you assert against a response you wrote | The mock drifts from reality; the test passes while the integration is broken |
| HTTP-layer record/replay (vcr, nock, msw) | Fast, free | The transport, and whatever ran before the network call | Mocks the socket, so your tool dispatch and tools never run; couples fixtures to SDK internals |
| SDK-boundary record/replay (e.g. cassette) | Fast, free, deterministic | Your whole loop — tool dispatch, tools, parsing — with only the model answers replayed | You re-record when the prompt or expected behaviour genuinely changes (a feature, not a bug) |
This is the insight that most LLM testing advice skips. An agent's interesting behaviour is not in the network call — it's in the loop *around* it. The model returns a `tool_use` block; your code has to parse it, dispatch the right tool, coerce the arguments, run the tool, handle its errors, and format the result back into the next request. That parsing-and-dispatch path is where agents actually break.
Now look at what each mocking layer does to that path:
Rule of thumb: replay the thing you don't control (the model's words) and run the thing you do (your code). HTTP-layer mocking gets this backwards — it replays your transport and skips your logic. It's the right tool for a plain REST client and the wrong one for an agent.
Here's the whole pattern with cassette. You wrap the run once; on the first pass it records the model's responses to a file, and every run after that replays them — no network, no API key, deterministic.
import Anthropic from '@anthropic-ai/sdk';
import { withCassette, anthropicAdapter } from '@duskel/cassette';
import { expect, test } from 'vitest';
test('the agent converts the temperature it looked up', async () => {
const client = new Anthropic();
const run = await withCassette('weather-agent', async (tape) => {
tape.use(anthropicAdapter, client); // patch the SDK boundary
return runAgent(client, 'Weather in Paris, in fahrenheit?');
});
// runAgent's tool dispatch + tools ran for real; only the model replied from tape
expect(run.answer).toContain('69.8');
});Record once with the mode set to `record` (and an API key present); commit the resulting cassette file; then CI runs in `replay` with no key and no network. Because the model's answers are frozen, the test is deterministic — and because your loop still runs, it's a real test, not a mock theatre. When you genuinely change the prompt or the expected behaviour, you re-record on purpose. Two practical notes: secrets in recorded requests should be redacted before the cassette is committed (cassette does this), and for tools that are themselves slow, paid or non-deterministic you can opt to record the tool boundary too.
Being honest about the boundary matters, because record/replay solves one problem and not another. It makes your *integration* deterministic — does my code correctly handle the model's output? It does not tell you whether the model's output is any *good*.
A production AI system needs both: fast deterministic integration tests on every commit, and an eval suite that measures output quality over time. Using record/replay for the first frees you to spend real model budget only on the second, where it actually earns its keep.
We built cassette because we ship production AI systems and kept watching agent test suites die the same death — too slow and flaky to run, then quietly abandoned. Getting the testing layer right is part of what separates an agent that survives real traffic from a demo, which is the same reason we care about evals and guardrails; we wrote that up in how to choose an AI agent development company.
If you're building an AI feature and want it tested so the suite actually gets run — deterministic integration tests plus an eval set that measures quality — tell us what you're building and we'll scope it. Or just `npm i -D @duskel/cassette` and freeze your first flaky test.
Record the model's responses once and replay them from a file on every subsequent run, so tests are fast, free, and deterministic. The key is recording at the SDK boundary (patching the client method) rather than the HTTP layer, so your own agent loop, tool dispatch and tools still execute for real — only the model's answers come off the recording. That gives you a genuine integration test with no API key and no network in CI. Tools like the open-source @duskel/cassette implement exactly this pattern.
HTTP-layer mocks (nock, vcr, msw) replace the transport, so when the model returns a tool call, your code that parses it, dispatches the tool, and runs it never executes — the part of an agent that actually breaks is left untested. They also couple your fixtures to SDK internals, so an SDK upgrade can invalidate recordings of conversations that didn't change. HTTP mocking is right for a plain REST client and wrong for an agent; recording at the SDK boundary keeps your logic running while replaying only the model's answers.
Freeze the non-deterministic part — the model's output — by recording it once and replaying it, while letting everything you control run for real. Because the same recorded answer comes back every time, assertions stop flaking. You deliberately re-record only when you change the prompt or the expected behaviour. This makes the test stable enough to run on every commit, which is the whole point: a flaky test gets muted, and a muted test is a deleted test that still shows green.
No — and that's an important boundary. Record/replay makes your integration deterministic (does your code correctly handle the model's output?), but it can't tell you whether the output is any good, because you're replaying a frozen answer. Output quality — right tool choice, staying on policy, refusing bad requests — needs an eval set scored against live model output on a separate cadence. A production AI system needs both: fast deterministic integration tests on every commit, and evals that measure quality over time.
When you deliberately change something that should change the model's response: a new prompt, a different tool schema, or a changed expected behaviour. That re-record is a feature, not a maintenance burden — it forces you to look at the new model output and confirm it's what you want before committing the updated recording. If a test starts failing without you changing anything, that's a real regression in your code, which is exactly what you want the test to catch.
A software studio that ships and maintains its own products — KeepChats, Gwora and Cairn — and builds the same way for clients. Founded and led by codewithumar.
Talk to the studio →Send the problem. You get one fixed number and a plan back within a business day.
We build software worth keeping — for clients, and for ourselves.
Founded & led by codewithumar