Every LLM project I have worked on eventually hits the same wall. Your eval suite and your unit tests both want to call a model, and the moment they do, CI slows to a crawl, costs money, needs secret keys, and flakes whenever the vendor changes a checkpoint. The output is nondeterministic by design, so a "did the pipeline work" test starts failing for reasons that have nothing to do with your code.
The fix I keep reaching for is a deterministic mock provider. It implements the exact same interface as your real providers, returns the same shape of response, and is completely offline. Tests that exercise your scoring, aggregation, and reporting logic never touch a network. In this tutorial I will build one from scratch. The code is grounded in my project evalgate, which ships exactly this, but the technique transfers to any stack.
Start with an interface both sides share
The whole trick depends on one thing: your real provider and your mock must be interchangeable. So define a narrow interface first and make every adapter conform to it.
export interface ProviderRequest {
model: string;
messages?: { role: string; content: string }[];
prompt?: string;
temperature?: number;
maxTokens?: number;
}
export interface ProviderResponse {
output: string;
latencyMs: number;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
costUsd?: number;
model: string;
raw?: unknown;
}
export interface Provider {
readonly name: string;
complete(request: ProviderRequest): Promise<ProviderResponse>;
embed?(text: string): Promise<number[]>;
}
Note that the response carries latency, token usage, and cost, not just text. If your mock only returns a string, every code path that reads usage or costUsd stays untested. Model the full shape.
Collapse the request to a single prompt
Requests can arrive as a single prompt or as a list of chat messages. The mock only needs one canonical string to reason about, so normalize first.
export function resolvePrompt(request: ProviderRequest): string {
if (request.prompt !== undefined) return request.prompt;
if (request.messages && request.messages.length > 0) {
return request.messages.map((m) => `${m.role}: ${m.content}`).join("\n");
}
return "";
}
Make it deterministic with a seeded hash
The core promise is that the same input always yields the same output. I derive everything from a hash of the prompt and feed that seed into a small pseudo random generator. FNV-1a plus mulberry32 are both a few lines each and have zero dependencies.
const seed = fnv1a(prompt);
const rand = mulberry32(seed);
const latencyMs = Math.round(5 + rand() * 50); // stable "latency" in 5-55ms
Because the seed comes from the prompt, even the fake latency is reproducible. Run the suite a thousand times and you get identical numbers, which is what makes snapshot style assertions possible.
Two ways to control the output: fixtures and directives
A useful mock is not random. It is steerable. I give it two levers.
Fixtures are canned responses matched as case insensitive substrings against the prompt. The first match wins. This lets a specific test pin an exact output without caring how the mock works internally.
for (const [needle, response] of Object.entries(this.fixtures)) {
if (prompt.toLowerCase().includes(needle.toLowerCase())) return response;
}
Directives are instruction verbs embedded in the prompt itself, so an example suite can exercise real scorer behavior without any per test wiring. I scan for the last verb: body line and act on it.
switch (verb) {
case "exactly":
case "echo":
case "json":
return payload;
case "uppercase":
return payload.toUpperCase();
case "lowercase":
return payload.toLowerCase();
case "reverse":
return [...payload].reverse().join("");
}
Now a case whose prompt ends with exactly: {"ok": true} deterministically produces that JSON, and your JSON scorer gets a real input to grade. Anything with no fixture and no directive falls back to a stable pseudo response derived from the hash, so it is still reproducible:
const base = `mock response for prompt hash ${fnv1a(prompt)}`;
The part people forget: a degrade mode
Here is the feature that earns its keep. A green test suite proves your scorers accept good output. It does not prove they reject bad output. If your exact match scorer has a bug that always returns 1.0, every test passes and you never notice.
So I add a degrade flag that deterministically corrupts the output, simulating a dumber model or a regression.
function corrupt(text: string): string {
if (text.length === 0) return "?";
// Break exact match while keeping partial similarity intact.
return text.slice(0, Math.max(1, text.length - 1)) + " [oops]";
}
Dropping the last character and appending a marker is deliberate. It breaks exact match assertions but keeps enough overlap that a similarity scorer still returns a middling score, not zero. That lets you write a test asserting your pipeline scores lower in degrade mode, which is how you prove the scorers actually discriminate.
Wire it through a registry
Finally, make the mock the default so no configuration means no network. A tiny registry maps provider names to lazy factories.
registry.register("mock", () => new MockProvider(mockOptions));
registry.register("openai", (e) => openai(e));
registry.register("anthropic", (e) => anthropic(e));
Your suite references a provider by name. In CI you point it at mock and everything runs offline, for free, in milliseconds. Locally, flip it to a real provider when you actually want to measure model quality.
The one honest caveat
A mock provider tests your harness, not your model. It will tell you that your scoring, aggregation, cost accounting, and reporting all work. It will never tell you whether GPT or Claude actually answers your prompts well, because it is not a language model. Deterministic mocks and real model evals are complementary. Run the mock on every commit to protect your plumbing, and run real model evals on a schedule or before a release to measure quality. Confusing the two is how teams end up with a perfectly green suite and a product that gives bad answers.
If you want to see all of this wired together with scorers, a runner, and example suites, the full implementation lives in github.com/AgentPostmortem/evalgate. Steal the pattern and give your test suite its keys back.
Top comments (5)
The point about modeling the full response shape — latency, usage, costUsd — not just the output string is the part most people skip, and it's the part that pays off. Once your mock returns realistic usage numbers, you can unit-test your budget guards and cost-aggregation paths offline, which is where the expensive bugs actually hide (a token-counting regression costs real money the day it ships, not in the eval). One distinction we found worth drawing explicitly: a deterministic mock is perfect for testing that your harness plumbing works — scoring, retries, aggregation, reporting — but it can't tell you anything about model quality, and it's tempting for a team to start treating green CI as "the agent works." We ended up tagging the two suites differently so nobody confuses "the pipeline ran" with "the model was good." Question on the seeded hash: do you seed purely off the prompt, or also off model + temperature? We wanted the mock to return different deterministic outputs per model so we could exercise routing logic, which meant folding the model name into the seed.
Two details in the fixture matcher that bite later.
Object.entries(this.fixtures)is not insertion-ordered for integer-like keys: they come first, in ascending numeric order, so a fixture needle like"42"always beats"hello"regardless of which you declared first, and "first match wins" is not what the code does. And with case-insensitive substring matching, a short needle shadows a longer one that would have been the intended match ("cat"fires on a prompt about acategory). Using aMap, or an array of[needle, response]pairs sorted by needle length descending, makes the winner predictable.A cheap guard is a test that loads all fixtures and asserts no needle is a substring of another unless the longer one is listed first. Do your eval suites ever put the same fixture set into more than one test file?
Agree that the mock is the right layer for scorers, aggregation and cost paths. One layer up there's a gap it can't cover: the integration test where a real model has to produce the input your scorer grades, because that's where prompt or template changes break things.
marcusykim's sequential / concurrent / reverse-order check is a good way to see the gap. A real local model fails it on a normal server: in my measurement, llama.cpp's server with 8 parallel slots changed the generated text on 41 of 100 gsm8k items between 1 and 8 concurrent requests, same file, greedy. So people pin the real provider to one request at a time with the prompt cache off, and the suite gets slow.
Disclosure: I built a local engine for exactly this case, GeulPU. It gives the same bits with any number of concurrent requests and with the prompt cache on, and it speaks the OpenAI-compatible API, so it slots in as one more
Provideradapter next to your mock. It also returns a per-step logit hash, which makes snapshot assertions on a real model possible. Limits: Windows builds, dense GGUF models only. github.com/wwoosshh/GeulPUv2The prompt-seeded latency gives you stable reports, but I'd test the runner with the same cases sequentially, concurrently, and in reverse order. The per-case output, token totals, and cost should match in all three runs; otherwise a shared PRNG or aggregation state has slipped across the provider boundary. I'd also block network access in the mock CI job, rather than relying only on the registry default. That turns an accidentally selected real adapter into a clear test failure instead of a quiet bill. Keep separate real-provider contract tests for response-shape drift, since the mock can stay green after a vendor changes its payload.
degrade 모드로 점수가 실제로 낮아지는지 확인한다는 부분이 특히 좋네요. 출력 문자열을 망가뜨리는 경우와 별도로, 정해진 오류를 반환하는 provider나 usage가 없는 응답도 시험 사례로 두면 재시도·비용 집계·실패 보고가 각각 어떻게 동작해야 하는지 더 명확하게 검증할 수 있을 것 같습니다.