集成测试
通过组织测试套件、管理密钥、应对偶发波动与控制成本,使用真实 LLM API 测试 Agent。
集成测试用于验证你的 Agent 是否能够与模型 API 及外部服务正常协同工作。与使用 Fake(伪模型)和 Mock 的单元测试不同,集成测试会发起真实的网络调用,以确认各组件能够协同工作、凭据有效且延迟处于可接受范围内。
由于 LLM 的响应具有不确定性,集成测试需要采用不同于传统软件测试的策略。本指南涵盖了如何为 Agent 组织、编写和运行集成测试。关于为 LangChain 本身贡献代码时的通用测试基础设施,请参阅贡献代码。
分离单元测试与集成测试
集成测试的运行速度较慢且需要 API 凭据,因此应将其与单元测试分开。这样你可以在每次代码变更时运行快速的单元测试,并将集成测试留给 CI 或预部署检查。
使用文件命名规范来分离集成测试。将集成测试文件命名为 *.int.test.ts,并配置 vitest 将其从默认运行中排除:
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig((env) => {
if (env.mode === "int") {
return {
test: {
testTimeout: 100_000,
include: ["**/*.int.test.ts"],
setupFiles: ["dotenv/config"],
},
};
}
return {
test: {
testTimeout: 30_000,
exclude: ["**/*.int.test.ts", ...configDefaults.exclude],
},
};
});向 package.json 添加脚本:
{
"scripts": {
"test": "vitest",
"test:integration": "vitest --mode int"
}
}显式运行集成测试:
npm run test:integration管理 API 密钥
集成测试需要真实的 API 凭据。请从环境变量中加载它们,以确保密钥不会进入版本控制系统。
将 dotenv/config 添加为 vitest 的 setup 文件,以便自动从 .env 加载环境变量:
export default defineConfig({
test: {
setupFiles: ["dotenv/config"],
},
});OPENAI_API_KEY=sk-...当缺失密钥时跳过测试:
import { test } from "vitest";
test.skipIf(!process.env.OPENAI_API_KEY)(
"agent responds with tool call",
async () => {
// ...
}
);WARNING
将 .env 添加到你的 .gitignore 中以避免提交凭据。在 CI 环境中,请通过服务商的机密管理工具(例如 GitHub Actions secrets)注入密钥。
断言结构而非内容
LLM 的响应在不同运行之间存在差异。不要对精确的输出字符串进行断言,而是验证响应的结构属性:消息类型、工具调用名称、参数形态以及消息数量。
test("agent calls weather tool", async () => {
const agent = createAgent({ model: "claude-sonnet-4-6", tools: [getWeather] });
const result = await agent.invoke({
messages: [new HumanMessage("What's the weather in SF?")]
});
const aiMsg = result.messages.find(
(m) => AIMessage.isInstance(m) && m.tool_calls?.length
);
expect(aiMsg).toContainToolCall({ name: "get_weather" });
expect(result.messages.at(-1)).toBeAIMessage();
});该示例使用了自定义测试匹配器。有关安装配置和完整的匹配器参考,请参阅下文。
TIP
如需进行更严格的轨迹断言,请使用支持 unordered 和 superset 等模糊匹配模式的 AgentEvals 评估器。
使用自定义测试匹配器
langchain 提供了自定义 vitest 匹配器,使结构化断言更具可读性,并在断言失败时提供清晰的错误信息。只需在 setup 文件中注册一次,它们即可在每次 expect() 调用中使用。
配置
添加一个 vitest setup 文件,使用 LangChain 匹配器扩展 expect:
import { langchainMatchers } from "@langchain/core/testing";
expect.extend(langchainMatchers);在你的 vitest 配置中引用它:
export default defineConfig({
test: {
setupFiles: ["vitest.setup.ts"],
},
});TypeScript 类型会自动包含在内,因此代码自动补全无需任何额外配置。
检查消息类型
每个消息类都有一个对应的匹配器:toBeHumanMessage()、toBeAIMessage()、toBeSystemMessage() 和 toBeToolMessage()。不传参数调用时仅检查类型,或者传入字符串以同时匹配内容:
const response = await agent.invoke({
messages: [new HumanMessage("What's the weather?")]
});
const lastMessage = response.messages.at(-1);
expect(lastMessage).toBeAIMessage();
expect(lastMessage).toBeAIMessage("It's 72°F and sunny.");传入一个对象以匹配特定字段:
expect(lastMessage).toBeAIMessage({ name: "weather-bot" });
expect(toolMsg).toBeToolMessage({ tool_call_id: "call_1" });断言工具调用
有三个匹配器用于对 AIMessage 的工具调用进行断言:
const response = await agent.invoke({
messages: [new HumanMessage("Weather in SF and NYC?")]
});
const aiMsg = response.messages.find(
(m) => AIMessage.isInstance(m) && m.tool_calls?.length
);
// 检查是否存在特定的工具调用(与顺序无关)
expect(aiMsg).toHaveToolCalls([
{ name: "get_weather", args: { city: "San Francisco" } },
{ name: "get_weather", args: { city: "New York" } },
]);
// 仅检查数量
expect(aiMsg).toHaveToolCallCount(2);
// 检查是否至少有一个工具调用匹配(支持 .not)
expect(aiMsg).toContainToolCall({ name: "get_weather" });
expect(aiMsg).not.toContainToolCall({ name: "send_email" });断言工具消息
toHaveToolMessages() 接收完整的消息数组,并按顺序检查其中的 ToolMessage 实例:
expect(response.messages).toHaveToolMessages([
{ content: "72°F and sunny in San Francisco" },
{ content: "68°F and cloudy in New York" },
]);断言中断与结构化响应
toHaveBeenInterrupted() 用于检查 LangGraph 中断(interrupt)结果中的 __interrupt__ 字段。传入一个值以匹配中断负载(payload):
const result = await graph.invoke(input);
expect(result).toHaveBeenInterrupted();
expect(result).toHaveBeenInterrupted("confirm_action");toHaveStructuredResponse() 检查结果上的 structuredResponse 字段。传入一个对象以匹配特定字段:
expect(result).toHaveStructuredResponse();
expect(result).toHaveStructuredResponse({ name: "Alice", age: 30 });匹配器参考
| Matcher | Description |
|---|---|
toBeHumanMessage(expected?) | 检查值是否为 HumanMessage。可选匹配内容(字符串)或字段(对象)。 |
toBeAIMessage(expected?) | 检查值是否为 AIMessage。可选匹配内容或字段。 |
toBeSystemMessage(expected?) | 检查值是否为 SystemMessage。可选匹配内容或字段。 |
toBeToolMessage(expected?) | 检查值是否为 ToolMessage。可选匹配内容或像 tool_call_id 这样的字段。 |
toHaveToolCalls(expected) | 检查 AIMessage 是否精确包含给定的工具调用(与顺序无关)。 |
toHaveToolCallCount(n) | 检查 AIMessage 是否精确拥有 n 个工具调用。 |
toContainToolCall(expected) | 检查 AIMessage 是否包含至少一个匹配的工具调用。支持 .not。 |
toHaveToolMessages(expected) | 检查消息数组是否按顺序包含给定的 ToolMessage 实例。 |
toHaveBeenInterrupted(value?) | 检查结果是否包含 __interrupt__。可选匹配中断的值。 |
toHaveStructuredResponse(expected?) | 检查结果是否包含 structuredResponse。可选匹配特定字段。 |
降低成本与延迟
调用 LLM API 的集成测试会产生实际费用。以下几种实践有助于保持测试套件的高速运行且经济实惠:
- 使用较小的模型:对于只需验证工具调用和响应结构的测试,使用
gemini-3.1-flash-lite或同级别模型。 - 设置
maxTokens:限制响应长度,避免产生冗长且昂贵的补全内容。 - 限制测试范围:每个测试仅验证单一行为。当单轮测试已足够时,避免使用串联多次 LLM 调用的端到端场景。
- 选择性运行:利用上文的测试分离方案,仅在 CI 中或部署前运行集成测试,而不是在每次保存文件时都运行。
const agent = createAgent({
model: "gemini-3.1-flash-lite",
tools: [getWeather],
modelArgs: { maxTokens: 256 },
});下一步
在评测 (Evals)中了解如何通过确定性匹配或 LLM 作为裁判(LLM-as-judge)评估器来评估 Agent 的执行轨迹。
