Skip to content

Dependency Injection for Testing an LLM Client

9 min read · updated August 11, 2026

Whether faking a model call costs five lines or a refactor is decided long before you write a test. It is decided by one line: where the client object is constructed.

The shape that resists testing

This is the shape almost every quickstart produces, and it is the one that will fight you:

// src/llm.ts
import OpenAI from "openai";
export const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// src/triage.ts
import { client } from "./llm";

export async function triage(ticket: string) {
  const res = await client.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: ticket }],
  });
  return res.choices[0].message.content;
}

There are two problems and only one of them is obvious. The obvious one is that triage has no parameter you can substitute. The subtler one is that constructing the client happens at module evaluation: the moment your test file imports anything that transitively imports src/llm.ts, the constructor runs. Some clients read and validate the key eagerly, so a test that never intended to touch the network fails during import with a message about a missing API key, on a file whose name appears nowhere in the stack you care about.

Why mocking the module hurts

The usual response is to mock the module. It can be made to work, and the cost is worth seeing clearly. In vitest, vi.mock calls are hoisted to the top of the file, above your imports, so a factory that closes over a variable declared with const in the test file throws ReferenceError: Cannot access 'fake' before initialization. The documented escape is vi.hoisted:

import { vi, expect, it } from "vitest";

const { create } = vi.hoisted(() => ({ create: vi.fn() }));

vi.mock("./llm", () => ({
  client: { chat: { completions: { create } } },
}));

const { triage } = await import("./triage");

it("returns the model's content", async () => {
  create.mockResolvedValue({
    choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content: "billing" } }],
  });
  expect(await triage("card declined")).toBe("billing");
});

Count what that costs. You have hand-built the nesting chat.completions.create, which means you have hard-coded the SDK’s object shape into your test; you have added a dynamic import to defeat hoisting; and the mock is scoped to a module path, so two tests in one file that need different clients fight each other. None of that is testing your triage logic. It is testing your ability to reproduce somebody else’s API surface from memory.

Module mocking is not always wrong — it is the right tool when you genuinely cannot change the code under test, such as a vendored module or a third-party package. It is the wrong tool when the code is yours and the seam is one parameter away.

The narrowest useful seam

Do not inject the SDK client. Inject an interface you own, whose methods are the operations your application actually performs. It should be small enough to implement by hand in a few lines, which is the whole point.

// src/ports.ts
export type Completion = {
  text: string;
  finishReason: "stop" | "length" | "tool_calls" | "content_filter";
  usage: { inputTokens: number; outputTokens: number };
};

export interface ChatModel {
  complete(req: {
    system: string;
    user: string;
    maxTokens?: number;
  }): Promise<Completion>;
}

Two things follow immediately. Your application code no longer names a vendor type anywhere, so swapping or adding a provider is one adapter rather than a search across the codebase. And the fake you write in a test is an object with one method, not a reconstruction of a nested client. That fake is the subject of stubbing a client by hand, which picks up exactly where this page stops.

Note what the interface deliberately keeps: finishReason and usage. It is tempting to reduce complete to returning a string, and it is a mistake, because those two fields are how your code learns that an answer was truncated and what the call cost. An interface that drops them makes truncation untestable, since a truncated answer and a complete one are both just strings.

Resist two variations that look like improvements. Injecting the vendor client itself as a constructor parameter buys you the seam but keeps the vendor’s object shape in every test, so you have paid for the refactor and kept the drift. Injecting a bare function — (prompt: string) => Promise<string> — is too small: the first time you need streaming, tool calls or usage you have to widen the signature everywhere it is passed, whereas a named interface with one method absorbs that change in one file.

The other question is where to draw the line when a class needs two different kinds of model call. Two methods on one port is usually right when they share a provider and a budget. Two separate ports is right when one of them is a genuinely different thing — an embedding call and a chat call have different inputs, different failure modes and different prices, and a single complete that switches on a flag makes both of them harder to fake and harder to read.

Wiring it without a container

You do not need a DI framework for this. Pass the dependency to the constructor, or to the function, and construct the real one once at the edge of the process.

// src/triage.ts
import type { ChatModel } from "./ports";

export class Triage {
  constructor(private readonly model: ChatModel) {}

  async classify(ticket: string) {
    const res = await this.model.complete({
      system: "Classify the ticket. Reply with one word.",
      user: ticket,
      maxTokens: 8,
    });
    if (res.finishReason === "length") throw new Error("classification truncated");
    return res.text.trim().toLowerCase();
  }
}

// src/main.ts — the only place the vendor is named
import { OpenAiChatModel } from "./adapters/openai";
const triage = new Triage(new OpenAiChatModel(process.env.OPENAI_API_KEY!));

The adapter is the one class that touches the SDK, and it is the one class you test against an intercepted HTTP layer rather than against a fake, because its whole job is to be correct about the wire format. Everything above it tests against the interface, in memory, at full speed.

If a framework insists on constructing your handlers for you and there is no obvious edge to wire from, a lazily-initialised module-level factory is an acceptable compromise — but only as long as the class itself still takes the dependency as a parameter. The factory is the wiring and the constructor is the seam, and only the seam is what your tests touch. The moment the class reaches for the factory directly you are back to the singleton and the import-time construction that started this page.

What the seam lets you assert

The seam is not only about avoiding the network. It converts a class of behaviour that was previously invisible into something you can assert on directly, because every call now passes through an object you control:

  • Call count. That a cached result did not call the model twice. That a batch of ten items produced one call, not ten.
  • Arguments. That the system prompt is the one from the registry and not an inline string somebody pasted in during a hotfix.
  • Budgets. That maxTokens is set on a path where an unbounded answer would be expensive.
  • Failure handling. A fake that rejects lets you test the catch block, which is otherwise reachable only by breaking the provider on purpose.
  • Sequencing. A fake that returns a different value per call lets you test a retry path or a multi-step chain without any timing at all.

All five of those are assertions about your code. None of them asserts anything about what a model would say, which is the only way this suite stays green for the right reasons.