Skip to content

集成测试用于验证智能体能否与模型 API 和外部服务正确协同工作。与使用假对象(fake)和模拟对象(mock)的单元测试不同,集成测试会发起真实的网络调用,以确认各组件能够协同工作、凭据有效,且延迟可接受。

由于 LLM 响应具有非确定性,集成测试需要与传统的软件测试不同的策略。本指南介绍如何为您的智能体组织、编写和运行集成测试。有关在向 LangChain 本身贡献代码时的一般测试基础设施,请参阅为代码做贡献

区分单元测试与集成测试

集成测试速度较慢,并且需要 API 凭据,因此应将其与单元测试分开。这样您就可以在每次更改时运行快速的单元测试,并将集成测试保留给 CI 或部署前检查。

使用 pytest 标记(marker)标注集成测试:

python
import pytest

@pytest.mark.integration
def test_agent_with_real_model():
    agent = create_agent("claude-sonnet-4-6", tools=[get_weather])
    result = agent.invoke({
        "messages": [HumanMessage(content="What's the weather in SF?")]
    })
    assert len(result["messages"]) > 1

配置 pytest 识别该标记,并从默认运行中排除集成测试:

ini
[pytest]
markers =
    integration: tests that call real LLM APIs
addopts = -m "not integration"
toml
[tool.pytest.ini_options]
markers = [
  "integration: tests that call real LLM APIs"
]
addopts = "-m 'not integration'"

显式运行集成测试:

bash
pytest -m integration

使用文件命名约定来区分集成测试。将集成测试文件命名为 *.int.test.ts,并配置 vitest 从默认运行中排除它们:

ts
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

json
{
  "scripts": {
    "test": "vitest",
    "test:integration": "vitest --mode int"
  }
}

显式运行集成测试:

bash
npm run test:integration

管理 API 密钥

集成测试需要真实的 API 凭据。请从环境变量中加载它们,以确保密钥不进入版本控制。

使用 conftest.py 的 fixture 来验证所需的密钥是否可用:

python
import os
import pytest

@pytest.fixture(autouse=True)
def check_api_keys():
    if not os.environ.get("OPENAI_API_KEY"):
        pytest.skip("OPENAI_API_KEY not set")

对于本地开发,请将密钥存储在 .env 文件中,并使用 python-dotenv 加载它们:

bash
OPENAI_API_KEY=sk-...
python
from dotenv import load_dotenv

load_dotenv()

dotenv/config 添加为 vitest 的 setup 文件,以便自动从 .env 加载环境变量:

ts
export default defineConfig({
  test: {
    setupFiles: ["dotenv/config"],
  },
});
bash
OPENAI_API_KEY=sk-...

在缺少密钥时跳过测试:

ts
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 响应在每次运行之间会有所不同。不要断言确切的输出字符串,而应验证响应的结构属性:消息类型、工具调用名称、参数结构以及消息数量。

python
def test_agent_calls_weather_tool():
    agent = create_agent("claude-sonnet-4-6", tools=[get_weather])
    result = agent.invoke({
        "messages": [HumanMessage(content="What's the weather in SF?")]
    })

    messages = result["messages"]
    tool_calls = [
        tc
        for msg in messages
        if hasattr(msg, "tool_calls")
        for tc in (msg.tool_calls or [])
    ]

    assert any(tc["name"] == "get_weather" for tc in tool_calls)
    assert isinstance(messages[-1], AIMessage)
    assert len(messages[-1].content) > 0
ts
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

若要进行更严格的轨迹断言,请使用 AgentEvals 评估器,它们支持 unorderedsuperset 等模糊匹配模式。

使用自定义测试匹配器

langchain 附带自定义 vitest 匹配器,它们使结构断言更具可读性,并在失败时生成清晰的错误消息。在 setup 文件中注册一次,即可在所有 expect() 调用中使用。

设置

添加一个 vitest setup 文件,使用 LangChain 匹配器扩展 expect

ts
import { langchainMatchers } from "@langchain/core/testing";

expect.extend(langchainMatchers);

在您的 vitest 配置中引用它:

ts
export default defineConfig({
  test: {
    setupFiles: ["vitest.setup.ts"],
  },
});

TypeScript 类型会自动包含在内,因此自动补全无需额外配置。

检查消息类型

每个消息类都有对应的匹配器:toBeHumanMessage()toBeAIMessage()toBeSystemMessage()toBeToolMessage()。不带参数调用仅检查类型,或传入字符串以同时匹配内容:

ts
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.");

传入对象以匹配特定字段:

ts
expect(lastMessage).toBeAIMessage({ name: "weather-bot" });
expect(toolMsg).toBeToolMessage({ tool_call_id: "call_1" });

断言工具调用

有三个匹配器可对 AIMessage 进行工具调用断言:

ts
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 实例:

ts
expect(response.messages).toHaveToolMessages([
  { content: "72°F and sunny in San Francisco" },
  { content: "68°F and cloudy in New York" },
]);

断言中断与结构化响应

toHaveBeenInterrupted() 会检查 LangGraph 中断 结果中的 __interrupt__ 字段。传入一个值以匹配中断负载:

ts
const result = await graph.invoke(input);

expect(result).toHaveBeenInterrupted();
expect(result).toHaveBeenInterrupted("confirm_action");

toHaveStructuredResponse() 会检查结果上的 structuredResponse 字段。传入一个对象以匹配特定字段:

ts
expect(result).toHaveStructuredResponse();
expect(result).toHaveStructuredResponse({ name: "Alice", age: 30 });

匹配器参考

匹配器描述
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 中或部署前运行集成测试,而不是在每次保存文件时运行。
python
agent = create_agent(
    "gemini-3.1-flash-lite",
    tools=[get_weather],
    model_kwargs={"max_tokens": 256},
)
ts
const agent = createAgent({
  model: "gemini-3.1-flash-lite",
  tools: [getWeather],
  modelArgs: { maxTokens: 256 },
});

录制并重放 HTTP 调用

对于在 CI 中频繁运行的测试,您可以在首次运行时录制 HTTP 交互,并在后续运行中重放它们,而无需发起真实的 API 调用。这消除了首次录制之后的成本和延迟。

vcrpy 将 HTTP 请求/响应对录制到 YAML "cassette" 文件中。pytest-recording 插件将其与 pytest 集成。

设置您的 conftest.py,从 cassettes 中过滤敏感信息:

py
import pytest

@pytest.fixture(scope="session")
def vcr_config():
    return {
        "filter_headers": [
            ("authorization", "XXXX"),
            ("x-api-key", "XXXX"),
        ],
        "filter_query_parameters": [
            ("api_key", "XXXX"),
            ("key", "XXXX"),
        ],
    }

配置您的项目识别 vcr 标记:

ini
[pytest]
markers =
    vcr: record/replay HTTP via VCR
addopts = --record-mode=once
toml
[tool.pytest.ini_options]
markers = [
  "vcr: record/replay HTTP via VCR"
]
addopts = "--record-mode=once"

INFO

--record-mode=once 选项在首次运行时录制 HTTP 交互,并在后续运行中重放它们。

使用 vcr 标记装饰您的测试:

python
@pytest.mark.vcr()
def test_agent_trajectory():
    agent = create_agent("claude-sonnet-4-6", tools=[get_weather])
    result = agent.invoke({
        "messages": [HumanMessage(content="What's the weather in SF?")]
    })
    assert any(
        tc["name"] == "get_weather"
        for msg in result["messages"]
        if hasattr(msg, "tool_calls")
        for tc in (msg.tool_calls or [])
    )

首次运行会发起真实的网络调用,并在 tests/cassettes/ 中生成一个 cassette 文件。后续运行会重放录制好的响应。

WARNING

当您修改提示词、添加新工具或更改预期轨迹时,您保存的 cassettes 将会过时,现有测试将会失败。请删除对应的 cassette 文件并重新运行测试,以录制新的交互。

后续步骤

评估中了解如何使用确定性匹配或 LLM 裁判评估器评估智能体轨迹。