框架 Mock 适合单次调用,但 LLM 客户端状态ful、多轮调用、token 累计,自定义 Fake 类更可读。关键前提是依赖注入接口而非直接导入客户端。
Mock 框架非常擅长替代只调用一次的方法。而 LLM 客户端是一个你反复调用的依赖,且调用之间存在状态——而这恰恰是手写 fake 开始比一堆 matcher 配置更具可读性的地方。
这个论点不是意识形态层面的。问题在于,你需要模拟的行为是有状态的,而有状态正是框架 mock 表达最差的那一类。
思考一个真实测试需要什么:第一次调用失败、第二次成功,从而触发重试路径。返回一次工具调用,然后接受工具结果并返回最终答案,从而运行一个双轮 agent 循环。只有当请求的 maxTokens 低于某个数值时才返回截断的答案。在整个会话中累积 token 用量,使得第四次调用时预算检查触发。上面每一种情况,在一个类里只需几行代码,而用框架则需要一串链式 matcher 配置——而且那个类是六个月后你依然能读懂的那个。
前提条件是一个"接缝"(seam)——一个你拥有的接口,通过注入而非导入来使用。如果你还没有这样一个接口,这是"为 LLM 客户端做依赖注入"这个主题要做的事,而且要先行,因为没有它就没有地方放置下面要讲的对象。
有一种情况框架会毫无争议地胜出,说清楚它可以防止这篇文章变成教条:只调用一次、调用之间无状态、且接口既不为你所有也不能修改的依赖。单独一个 vi.fn() 返回固定值比一个类更短,而且表达的意思同样多。本文论述的具体场景是模型客户端,它是有状态的、在一次逻辑操作中反复调用、且位于你自己写的接口之后。
// 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;
}
}
其中有三个决策值得点名。默认结果是抛出异常,而非预设的成功响应,因为对有偿依赖的意外调用是一个 bug,而一个默默吸收它的 fake 恰恰会掩盖你真正在测试什么。错误信息包含调用序号,因为"意外调用"没有ordinal就什么都告诉不了你。而 calls 是一个公开的普通数组而非一组查询方法,所以断言就是普通的 JavaScript,不需要来自任何库的词汇。
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
});
});
再读一遍第三个测试,因为它是离开了这个 double 就写不出来的那个。它断言的是一个 Absence——即同一次请求中,第二次逻辑请求没有变成对付费 API 的第二次调用——而 Absence 只有在有东西在计数时才是可观测的。thenAlways(new Error(...)) 把"缓存退化了"从一个静默的账单翻倍变成了一个具名的测试失败,并报告哪一次调用是意外的那次。
只有断言词汇来自测试运行器。把 vitest 换成 Node 内置的测试运行器,double 纹丝不动——如果你的代码库跨了好几个运行器,这是实实在在的好处。
参数 matcher。 Double 内部没有 expect.objectContaining 的等价物,所以一个只关心请求中某个字段的测试仍然要把整个对象从 calls 里取出来再对该字段做断言。实践中这没什么大不了,甚至可以说更清晰,但确实要多打一些字。
自动验证。 严格的 mock 在配置的期望从未被满足时会直接让测试失败。你的 fake 不会注意到排了队却没人消费的 reply。在关键的地方加一个辅助方法断言 script 为空——就是一个在 script.length > 0 时抛出的单一方法。
对你没有为其设计的东西进行 spy。 如果你事后想知道某次调用是发生在某个无关副作用之前还是之后,框架 mock 免费记录时间戳和顺序;你的 fake 只记录你告诉它记录的。
失败信息。 框架断言失败时会打印预期参数和实际参数的 diff。手动比较 calls 的条目只会得到你的运行器为两个对象打印的内容——对于一个大型请求体来说就是一大堵文字。不断言整个对象而是断言具体字段,这种情况大多会消失。
Partial double。 用框架保留九个真实方法、伪造一个轻而易举,而这里需要手写委托。如果接口很小这种情况很少发生,而这本身也是保持接口小巧的理由之一。
这些都不是致命的,对于这个特定依赖来说这个权衡通常是值得的。对于一个有三十个方法的大型遗留接口就不会是这样。
任何手写 double 都存在一个真正的风险:它不再像真实的东西,你的测试套件开始验证的是一个虚构物。三个廉价的防护措施:
声明 implements ChatModel 并在 CI 中检查类型。新方法或变更的签名就会在 double 中导致编译失败——这是发现问题最早的时机。
从接口派生参数类型,就像上面 Parameters<ChatModel["complete"]>[0] 那样,而不是重新声明它。重新声明的类型是一份副本,会悄无声息地变得过时。
从你的解析器测试使用的同一个 fixture 文件构建 fake 的预设响应,并从真实记录的流量中捕获这些 fixture 而不是手动输入。一个输出靠手捏造的 fake 测试的是你的想象力;输出被记录下来的 fake 测试的是你的代码。
最后一步的后半部分比 double 本身更重要。至少保留一个让适配器针对真实记录的响应运行的测试,这样 fake 回放的 fixture 已知是来自 provider 而不是来自代码审查。
一个习惯让整个方法更持久:把 double 放在测试树里,而不是 src 中。随生产包一起发货的 fake 会引诱人用它来做 feature flag——一个"试运行模式",悄悄返回预设答案——到了那个地步测试 double 就在决定你的用户看到什么。测试 double 属于 test/doubles/,只导出给测试,不导出给任何其他东西。
Testing Retry Logic Around an LLM API Call
Testing an AI Feature Without Calling the Model