Skip to content

Stubbing an LLM Client Without a Mocking Library

9 min read · updated August 11, 2026

A mocking framework is very good at standing in for a method you call once. An LLM client is a dependency you call repeatedly, with state between the calls, and that is precisely where a hand-written fake starts to read better than a stack of matchers.

Why hand-written wins for this dependency

The argument is not ideological. It is that the behaviour you need to simulate is stateful, and stateful is the thing framework mocks express worst.

Consider what a realistic test needs: fail on the first call and succeed on the second, so the retry path is exercised. Return a tool call, then accept the tool result and return a final answer, so a two-turn agent loop runs. Return a truncated answer only when the requested maxTokens is below some number. Accumulate token usage across a session so a budget check trips on the fourth call. Each of those is a couple of lines in a class and a sequence of chained matcher configurations in a framework, and the class is the one you can still read in six months.

The prerequisite is a seam — an interface you own, injected rather than imported. If you do not have one yet, that is the subject of dependency injection for an LLM client, and it comes first, because without it there is nowhere to put the object below.

There is a case where the framework wins outright, and naming it keeps this from reading as dogma: a dependency you call once, with no state between calls, behind an interface you neither own nor can change. A single vi.fn() returning a fixed value is shorter than a class and says just as much. The argument on this page is specific to a model client, which is stateful, called repeatedly within one logical operation, and sits behind an interface you wrote yourself.

The fake, in full

// test/doubles/fake-chat-model.ts
import type { ChatModel, Completion } from "../../src/ports";

type Scripted = Completion | Error;

export class FakeChatModel implements ChatModel {
  /** Every request this fake was asked to complete, in order. */
  readonly calls: Array<Parameters<ChatModel["complete"]>[0]> = [];

  private script: Scripted[] = [];
  private fallback: Scripted | undefined;

  /** Queue one outcome for the next call. Chainable. */
  reply(next: Partial<Completion> & { text: string }): this {
    this.script.push({
      finishReason: "stop",
      usage: { inputTokens: 100, outputTokens: 20 },
      ...next,
    });
    return this;
  }

  /** Queue a rejection for the next call. */
  fail(error: Error): this {
    this.script.push(error);
    return this;
  }

  /** Answer every call after the script runs out, instead of throwing. */
  thenAlways(next: Scripted): this {
    this.fallback = next;
    return this;
  }

  async complete(req: Parameters<ChatModel["complete"]>[0]): Promise<Completion> {
    this.calls.push(req);
    const next = this.script.shift() ?? this.fallback;
    if (!next) {
      throw new Error(
        `FakeChatModel: unexpected call #${this.calls.length}. ` +
          `Queue another reply() or fail(), or set thenAlways().`,
      );
    }
    if (next instanceof Error) throw next;
    return next;
  }
}

Three decisions in there are worth naming. The default outcome is an exception, not a canned success, because an unexpected call to a paid dependency is a bug and a fake that silently absorbs it hides exactly what you were testing for. The error message includes the call number, because “unexpected call” with no ordinal tells you nothing about which branch ran. And calls is a plain public array rather than a set of query methods, so an assertion is ordinary JavaScript and needs no vocabulary from a library.

Using it: three tests, no framework

import { describe, expect, it } from "vitest";
import { FakeChatModel } from "./doubles/fake-chat-model";
import { Triage } from "../src/triage";

describe("Triage", () => {
  it("sends the ticket and normalises the answer", async () => {
    const model = new FakeChatModel().reply({ text: "  Billing\n" });

    const label = await new Triage(model).classify("card declined");

    expect(label).toBe("billing");
    expect(model.calls).toHaveLength(1);
    expect(model.calls[0].user).toBe("card declined");
    expect(model.calls[0].maxTokens).toBe(8);
  });

  it("rejects a truncated classification instead of using half a word", async () => {
    const model = new FakeChatModel().reply({ text: "bil", finishReason: "length" });

    await expect(new Triage(model).classify("…")).rejects.toThrow(/truncated/);
  });

  it("does not call the model twice for the same ticket in one request", async () => {
    const model = new FakeChatModel().reply({ text: "billing" }).thenAlways(new Error("second call"));
    const triage = new Triage(model);

    await triage.classify("card declined");
    await triage.classify("card declined");

    expect(model.calls).toHaveLength(1);   // fails loudly if the cache regressed
  });
});

Read the third test again, because it is the one that could not be written without the double. It asserts an absence — that a second logical request did not become a second call to a paid API — and an absence is only observable if something is counting. thenAlways(new Error(...)) turns “the cache regressed” from a silent doubling of the bill into a named test failure that reports which call was the unexpected one.

Only the assertion vocabulary comes from the test runner. Swap vitest for node’s built-in test runner and the double is untouched, which is a real benefit if your codebase spans a few runners.

Four things you give up

  • Argument matchers. There is no expect.objectContaining equivalent inside the double, so a test that only cares about one field of a request still reads the whole object out of calls and asserts on the field. In practice this is fine and arguably clearer, but it is more typing.
  • Automatic verification. A strict mock fails the test when a configured expectation was never met. Your fake will not notice a queued reply that nobody consumed. Add a helper that asserts the script is empty and call it where it matters — a single method that throws if script.length > 0.
  • Spying on things you did not design for. If you later want to know whether a call happened before or after an unrelated side effect, a framework mock records timestamps and ordering for free; your fake records what you told it to.
  • Failure messages. When a framework assertion fails it prints a diff of expected against actual arguments. Comparing entries of calls by hand gives you whatever your runner prints for two objects, which on a large request body is a wall of text. Assert on the specific field rather than the whole object and this mostly disappears.
  • Partial doubles. Keeping nine real methods and faking one is trivial with a framework and requires delegation by hand here. With a small port interface this rarely comes up, which is itself an argument for keeping the interface small.

None of those is fatal, and the trade is usually worth it for this dependency specifically. It would not be for a large legacy interface with thirty methods.

Keeping it from drifting

The genuine risk with any hand-written double is that it stops resembling the real thing and your suite starts verifying a fiction. Three cheap guards:

  1. Declare implements ChatModel and check types in CI. A new method or a changed signature then breaks compilation in the double, which is the earliest possible moment to find out.
  2. Derive the argument type from the interface, as Parameters<ChatModel["complete"]>[0] does above, rather than restating it. A restated type is a copy that goes stale silently.
  3. Build the fake’s canned responses from the same fixture files your parser tests use, and capture those fixtures from real recorded traffic rather than typing them. A fake whose outputs were invented by hand tests your imagination; one whose outputs were recorded tests your code.

The second half of that last step matters more than the double itself. Keep at least one test that runs the adapter against a recorded real response, so the fixtures the fake replays are known to have come out of a provider rather than out of a code review.

One habit makes the whole approach durable: keep the double in the test tree, not in src. A fake that ships in the production bundle invites somebody to use it as a feature flag — a “dry run mode” that quietly returns canned answers — and at that point a test double is deciding what your users see. Test doubles belong under test/doubles/, exported to tests and to nothing else.