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 的逻辑往往不止于“模型说了一句话”,它还会根据模型的输出来决定是否调用外部工具,然后将工具执行结果塞回给模型进行二次总结。
模拟这种行为需要两点:
- 让
messages.create根据输入的上下文(当前轮次)返回不同的预设响应,而非永远返回同一个值。 - 让 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 分钟),完成了以下工作:
- 明确了 Skills 测试金字塔中单元、集成、端到端测试的边界,并把单元测试严格限定为全量 mock 模型行为的快速测试。
- 使用
unittest.mock.patch模拟了anthropic.Anthropic客户端,利用side_effect实现了正常文本响应与多轮工具调用链路。 - 练习了结构化输出的边界断言以及通过 mock 捕获文件写入等副作用。
一切围绕一个核心思想:把不确定的模型行为变成确定的可控数据,然后像测试纯函数一样测试你的 Skill。
行动清单
- 挑一个你项目中处理最复杂的 Skill,列出它所有可能的模型响应路径(文本回复、工具调用请求、异常回复)。
- 用本章提供的
make_mock_message工厂为每个路径生成对应的 mock 返回对象。 - 编写三条测试:正常文本路径、包含工具调用的完整链路、模型返回不合法结构时的降级行为。
- 运行测试,确保所有测试在无网络环境中通过,并记录单条耗时(应小于 0.5 秒)。
- 将这套 mock 模式沉淀为一个团队共用的 conftest fixture,新人写测试只需导入即可。
在完成单元测试的隔离覆盖之后,我们就有底气把目光移到更大的画布上——集成测试。下一章将把这些 Skill 的真实模型调用重新请回来,但以受控的沙箱方式构建一个回归测试套件,确保新版本发布前所有关键路径的端到端行为都不发生意外退化。
agent skills 入门到精通
关于 LearnKu