6.1. Skills 单元测试的核心难点是模拟模型行为

Skills 单元测试的核心难点是模拟模型行为

2026 年 4 月,我们的客户支持团队将 5 个核心 Skill 投入了生产。这些 Skill 负责解析用户意图、调用内部 API、生成标准化回复。一切正常运转——直到 Claude 模型服务有一次午夜故障击穿了重试策略,导致 Skill 直接返回原始错误堆栈给客户。事后复盘时我们发现:所有单元测试里压根没有模拟过“模型响应中的异常结构”,也没有验证过工具调用时参数缺失的情况。

这件事让我们看清一个事实:对 Skills 做单元测试,如果还在依赖真实模型调用,那不叫测试,那叫祈祷。

编写可靠且可重复的 Skill 单元测试,其核心难点永远只有一个——如何真实、可控、快速地模拟模型行为。本章不会教你“用 pytest 跑个 assert”,而是带你拆解模拟的完整策略,让你能在本地 0.5 秒内跑完一条 Skill 的完整逻辑路径,同时自信地把它推上 CI。


你需要什么

  • 一个使用 Anthropic Python SDK 实现了至少一个 Skill 的项目(使用 Claude Agent SDK 或直接调用 anthropic.Anthropic
  • Python 3.10+、pytest 和 unittest.mock
  • 预计完成本章时间:45 分钟

最终成果

得到一套独立于外部模型服务的 Skill 单元测试套件,它可以:

  • 在无网络环境下运行,单条测试耗时 < 500 毫秒
  • 覆盖正常响应、工具调用、异常模型行为三条关键路径
  • 提供清晰的 mock 策略,后续新增 Skill 时只需复制模板即可

之所以必须这么做,是因为真实模型调用有三座致命大山:成本高、速度慢、行为不确定。去掉这三个变量之后,你的测试才能真正聚焦 Skill 自身的逻辑是否正确。


测试金字塔在 Skills 中的映射

在动手写 mock 之前,先要搞清一件事:Skill 的单元测试到底在测哪一层?

下表给出了 Skills 项目里三层测试的明确边界。如果你过去一直把带有真实模型调用的测试称作“单元测试”,那么从今天起,请纠正这个定义——那应该是集成测试。

测试层级 目标 是否调用真实模型 典型耗时(单条) 适用场景
单元测试 验证 Skill 内部的编排逻辑、数据转换、条件分支 ❌ 全量 mock < 0.5s 每次 git push 都跑
集成测试 验证 Skill 与真实 Claude 模型(小版本)的交互,以及工具调用的真实链路 ✅ 真实调用(限流/沙箱) 3-20s 合并到主分支前跑
端到端测试 以真实用户视角触发完整工作流,覆盖入口、模型、工具、外部 API ✅ 全栈真实 > 30s 发布前/每日定时跑

“单元测试”这一层的最大价值,就是在 Skill 逻辑里找出所有“如果模型返回了 X,Skill 应该做 Y”的分支路径,并用 mock 把它们逐个覆盖。这一金字塔结构的落脚点是:模拟模型行为不是一种“不得已的妥协”,而是保证单元测试在 Skills 场景下仍然具备存在意义的前提。


步骤 1:搭建可复用的 Mock 基类

我们的 Skill 结构通常是:一个函数接收用户输入 → 构造系统提示与工具定义 → 调用 client.messages.create() → 解析响应并调用工具 → 再次调用模型(如有工具结果) → 返回最终结果。

单元测试的目标是用 unittest.mock.patch 替换掉 anthropic.Anthropic 实例的 messages.create 方法,让每一次假的“模型调用”都返回我们预设的响应。

首先,在你的测试目录里创建一个 mock_base.py,提供通用 fixture。

# tests/mock_base.py
import pytest
from unittest.mock import patch, MagicMock
from anthropic.types import Message, ContentBlock, Usage

# 构建一个可复用的模拟消息工厂函数
def make_mock_message(content_text: str, stop_reason: str = "end_turn") -> Message:
    """生成一个正常的文本响应 Message,用于 mock 模型返回。"""
    return Message(
        id="msg_mock_001",
        role="assistant",
        content=[ContentBlock(text=content_text, type="text")],
        model="claude-sonnet-4-20250514",
        stop_reason=stop_reason,
        stop_sequence=None,
        usage=Usage(input_tokens=10, output_tokens=20),
        type="message"
    )

@pytest.fixture
def mock_client():
    """Fixture 返回一个 mock 后的 Anthropic client,messages.create 可定制。"""
    with patch('anthropic.Anthropic') as MockClient:
        client_instance = MockClient.return_value
        # 默认的 create 返回一个空消息,测试里可以覆盖
        client_instance.messages.create.return_value = make_mock_message("默认响应")
        yield client_instance

预期结果mock_client 可以被任何测试直接注入,messages.create 不再触发网络请求,每次调用都会立即返回预设内容。


步骤 2:Mock 模型响应——正常文本与工具调用

这里才是真正考验的地方。Skill 的逻辑往往不止于“模型说了一句话”,它还会根据模型的输出来决定是否调用外部工具,然后将工具执行结果塞回给模型进行二次总结。

模拟这种行为需要两点:

  1. messages.create 根据输入的上下文(当前轮次)返回不同的预设响应,而非永远返回同一个值。
  2. 让 mock 能够返回工具调用(tool_use)格式的 ContentBlock。

下面的示例演示了一个简化版的“客户工单分类 Skill”,它会先让模型判断工单类型,如果模型请求调用数据库查询工具,Skill 再将查询结果传回模型生成回复。

# skills/ticket_classifier.py 中的 Skill 实现(简化)
from anthropic import Anthropic
from anthropic.types import ToolUseBlock, ToolResultBlock

def classify_ticket(client: Anthropic, user_message: str):
    tools = [{
        "name": "query_db",
        "description": "查询指定客户的工单历史",
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_id": {"type": "string"}
            }
        }
    }]

    # 第一轮调用
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=200,
        tools=tools,
        messages=[{"role": "user", "content": user_message}]
    )

    # 检查是否有工具调用
    tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
    if not tool_use_blocks:
        return response.content[0].text

    # 模拟执行工具
    tool_results = []
    for block in tool_use_blocks:
        if block.name == "query_db":
            # 真实环境会查数据库,这里简化为固定结果
            result = f"DB result for {block.input['customer_id']}: 3 open tickets"
            tool_results.append(
                ToolResultBlock(
                    tool_use_id=block.id,
                    type="tool_result",
                    content=result
                )
            )

    # 第二轮调用,将工具结果传回
    second_response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=200,
        tools=tools,
        messages=[
            {"role": "user", "content": user_message},
            {"role": "assistant", "content": response.content},
            {"role": "user", "content": tool_results}
        ]
    )
    return second_response.content[0].text

现在来为这个 Skill 编写两个测试:一个测试直接文本响应(模型不给工具调用),另一个测试完整的工具调用链路

# tests/test_ticket_classifier.py
from unittest.mock import patch
from anthropic.types import ContentBlock, ToolUseBlock
from skills.ticket_classifier import classify_ticket     # 你自己的模块
from tests.mock_base import make_mock_message

def test_direct_text_response(mock_client):
    """模型直接返回文本,不应触发任何工具调用。"""
    # 让 messages.create 始终返回同一个文本内容
    mock_client.messages.create.return_value = make_mock_message(
        "您的工单已归类为:技术问题。"
    )

    result = classify_ticket(mock_client, "我的API接口又超时了")
    assert "技术问题" in result
    # 确保只调用了一次模型(没有工具调用后的第二次)
    assert mock_client.messages.create.call_count == 1

def test_tool_use_and_second_turn(mock_client):
    """模型先要求调用 query_db,Skill 应发起二次模型请求。"""
    # 第一次调用返回工具调用请求
    tool_use_block = ContentBlock(
        type="tool_use",
        id="toolu_01A",
        name="query_db",
        input={"customer_id": "C12345"}
    )
    first_message = make_mock_message("")  # type: text 的 content 可空
    first_message.content.append(tool_use_block)

    # 第二次调用返回最终总结
    second_message = make_mock_message(
        "根据历史记录,您的工单属于技术中断问题,优先级为高。"
    )

    # 让 create 先返回第一次,再返回第二次
    mock_client.messages.create.side_effect = [first_message, second_message]

    result = classify_ticket(mock_client, "查询我的历史工单并给出严重等级")
    assert "优先级为高" in result
    assert mock_client.messages.create.call_count == 2  # 确认走了两轮
    # 还可以进一步检查第二次调用时 messages 参数里包含了工具结果
    second_call_kwargs = mock_client.messages.create.call_args_list[1][1]
    # 第二轮调用携带的消息内容中应存在 'DB result'
    messages_text = str(second_call_kwargs['messages'])
    assert "DB result for C12345" in messages_text

预期结果:运行 pytest tests/test_ticket_classifier.py -v 时,两个测试在 0.3 秒内全部通过,且过程中没有任何 HTTP 请求发出。

⚠️ 踩坑提醒:
当使用 side_effect 模拟多次调用时,必须确保 mock 返回的次序和 Skill 内部调用次序严格一致。否则你会得到“第二次调用拿到了第一次的响应”这种诡异的逻辑错误。建议在测试中检查工具调用块的 id 是否和你伪造的 tool_use_id 一致,以防 Skill 因为 id 不匹配而丢弃工具结果。


步骤 3:断言技巧——输出格式与副作用检查

模拟模型行为只是手段,测试的最终目的是验证 Skill 的行为是否正确。在 Skills 的单元测试中,除了断言文本内容,还经常需要检验:

  • 结构化输出:解析模型返回的 JSON,检查是否符合预期 schema。
  • 副作用:文件生成、数据库写入、日志记录等。

结构化输出验证

很多 Skill 要求模型以特定 JSON 格式输出,例如 {"category": "x", "urgency": 1}。因为我们是 mock 模型响应,所以可以刻意构造出错乱的 JSON来验证 Skill 的错误处理逻辑。

def test_output_parsing_handles_malformed_json(mock_client):
    """模型返回了一个非法的JSON字符串,Skill 应优雅降级。"""
    mock_client.messages.create.return_value = make_mock_message(
        '{"category": "bug", "urgency": '
    )
    # 预期 Skill 内的 json.loads 会失败,因此 Skill 应返回错误提示而非抛异常
    result = classify_ticket(mock_client, "请用JSON返回分类")
    # 这边假设 Skill 捕获了 JSONDecodeError 并返回一个包含 "解析失败" 的字段
    assert "解析失败" in result or "error" in result

副作用检查:文件生成与外部工具调用

另一个常见场景是 Skill 在处理完毕后会保存文件或调用内部函数。这些副作用应该成为断言的焦点,因为模型输出的正确只是第一步,如果文件没写对,业务同样会崩。

from unittest.mock import mock_open, call

@patch("builtins.open", new_callable=mock_open)
def test_skill_writes_report_file(mock_file, mock_client):
    """确认 Skill 在收到模型分析结果后写入了正确的报告文件。"""
    mock_client.messages.create.return_value = make_mock_message(
        "分析完成,报告摘要:系统健康度 98%"
    )
    # 假设我们的 Skill 内部调用 write_report("system_health.txt", "98%")
    # 通过 mock open 捕获写文件操作
    classify_ticket(mock_client, "生成健康报告")  # 该Skill内部会调用 write_report

    # 验证 open 被调用,文件内容包含了关键数据
    mock_file.assert_called_with("system_health.txt", "w")
    handle = mock_file()
    handle.write.assert_any_call("98%")  # 或者确保整体内容包含此字符串

预期结果:测试能够精确地捕获 Skill 对文件系统的操作,即使不进入真实写入流程,也能确保输出内容结构符合预期。

⚠️ 踩坑提醒:
不要在同一个测试文件里同时 mock 模型和真实数据库/文件。当你用 @patch("builtins.open") 时,请确保你的 Skill 代码使用的是该内置 open,并且在测试结束后 mocker 自动复原,否则会影响其他测试。


回顾

这一章我们花了大约 45 分钟(实操约 30 分钟 + 阅读概念 15 分钟),完成了以下工作:

  1. 明确了 Skills 测试金字塔中单元、集成、端到端测试的边界,并把单元测试严格限定为全量 mock 模型行为的快速测试
  2. 使用 unittest.mock.patch 模拟了 anthropic.Anthropic 客户端,利用 side_effect 实现了正常文本响应与多轮工具调用链路。
  3. 练习了结构化输出的边界断言以及通过 mock 捕获文件写入等副作用。

一切围绕一个核心思想:把不确定的模型行为变成确定的可控数据,然后像测试纯函数一样测试你的 Skill。


行动清单

  1. 挑一个你项目中处理最复杂的 Skill,列出它所有可能的模型响应路径(文本回复、工具调用请求、异常回复)。
  2. 用本章提供的 make_mock_message 工厂为每个路径生成对应的 mock 返回对象。
  3. 编写三条测试:正常文本路径、包含工具调用的完整链路、模型返回不合法结构时的降级行为。
  4. 运行测试,确保所有测试在无网络环境中通过,并记录单条耗时(应小于 0.5 秒)。
  5. 将这套 mock 模式沉淀为一个团队共用的 conftest fixture,新人写测试只需导入即可。

在完成单元测试的隔离覆盖之后,我们就有底气把目光移到更大的画布上——集成测试。下一章将把这些 Skill 的真实模型调用重新请回来,但以受控的沙箱方式构建一个回归测试套件,确保新版本发布前所有关键路径的端到端行为都不发生意外退化。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 查看所有版本


暂无话题~