2026/10/4 8:18:04

【Python智能体开发实战:RAG、工具调用与多智能体协作】如何测试依赖模型API的Python程序:用模拟客户端验证业务逻辑

【Python智能体开发实战:RAG、工具调用与多智能体协作】如何测试依赖模型API的Python程序:用模拟客户端验证业务逻辑 如何测试依赖模型API的Python程序用模拟客户端验证业务逻辑一、问题与目标你写了一个客服工单自动分类程序用户提交工单文本程序调用模型 API 返回分类标签再按标签路由到对应处理队列。本地开发时一切正常但每次跑测试都要等好几秒而且偶尔因为网络波动或额度限制而失败。更麻烦的是你想验证“模型返回了不在预期范围内的标签”时程序是否兜底但真实的模型几乎不会配合你制造这种异常。本文用一个最小可运行的工单分类程序演示如何用模拟客户端mock client替代真实的模型调用在完全离线、毫秒级完成的前提下测试业务逻辑的正常路径、边界情况和失败路径。完成后你会得到一个可独立运行的 Python 项目和一套可复用的验收方法。适用环境Python 3.10macOS / Linux / WSL 的 Bash。项目不发起任何真实网络请求不需要 API 密钥。二、案例与文件清单案例场景虚构的“晨光笔记”应用有一个工单分类模块。用户提交一段文本程序调用模型 API兼容 OpenAI SDK 的接口期望模型返回 JSON包含category字段取值限定为billing、technical、account、other四个之一。程序收到响应后校验 category 是否在允许集合内有效则返回该分类无效则返回other作为兜底。项目文件如下所有文件放在同一个隔离目录中文件名用途classifier.py被测业务逻辑构造请求、调用客户端、解析并校验响应mock_client.py模拟客户端替代openai.OpenAI返回可控的响应对象或抛出异常test_classifier.pypytest 测试文件覆盖正常、边界、失败三类场景conftest.pypytest 配置将项目根目录加入sys.pathrequirements.txt第三方依赖声明依赖分为两类。标准库json、unittest.mock。第三方库pytest测试运行器、openai仅用于类型参考测试中不会被真正实例化。实际安装版本见文末“验证状态”。三、为什么用模拟客户端而不是直接打桩Python 测试中常见两种替代外部依赖的策略打桩monkeypatch /unittest.mock.patch和模拟客户端。两者的区别在于替代的层次。打桩通常在业务代码导入外部库之后把业务模块中的某个引用替换掉例如patch(classifier.OpenAI)。这种方式有效但耦合了“业务代码如何导入和命名外部类”这个内部细节。一旦重构导入方式测试就可能因为找不到 patch 目标而失败。模拟客户端则是在依赖注入的边界上做替代。业务代码不自己创建客户端而是接收一个符合接口约定的对象。测试时传入模拟对象生产时传入真实客户端。这种方式的替代点更稳定只要接口约定不变内部重构不影响测试。本例选择模拟客户端的另一个原因是模型 API 的响应结构相对固定response.choices[0].message.content用模拟对象可以直接构造出与真实响应结构一致的对象测试代码读起来更接近真实调用链。需要明确的是模拟客户端验证的是业务逻辑对模型响应的处理是否正确不能替代真实 API 集成测试。模拟通过只说明分类逻辑、异常分支、兜底策略按预期工作真实模型的延迟、限流、格式偏差仍需在真实环境中单独验证。四、完整实现4.1classifier.py——被测业务逻辑工单分类业务逻辑。接收客户端对象调用模型校验并返回分类。importjsonfromtypingimportAny ALLOWED_CATEGORIES{billing,technical,account,other}SYSTEM_PROMPT(你是工单分类助手。只返回 JSON格式为 {\category\: \标签\}。标签只能是 billing、technical、account、other 之一。)defclassify_ticket(client:Any,ticket_text:str,model:strgpt-4o-mini)-str: 调用模型对工单文本分类。 参数: client: 兼容 openai.OpenAI 接口的客户端对象。 ticket_text: 用户提交的工单原文。 model: 模型标识默认 gpt-4o-mini。 返回: 合法分类标签模型返回无效内容或调用失败时返回 other。 try:responseclient.chat.completions.create(modelmodel,messages[{role:system,content:SYSTEM_PROMPT},{role:user,content:ticket_text},],temperature0.0,)exceptException:returnotherrawresponse.choices[0].message.contentifnotrawornotraw.strip():returnothertry:datajson.loads(raw)except(json.JSONDecodeError,TypeError):returnothercategorydata.get(category)ifcategoryinALLOWED_CATEGORIES:returncategoryreturnother关键设计说明except Exception捕获调用层面的失败网络错误、认证失败、超时等统一返回other。生产环境中你可能会区分错误类型做重试或告警但本文聚焦“业务逻辑是否正确处理失败”统一兜底足以演示。json.loads(raw)处理模型返回非 JSON 的情况。模型有时会返回 Markdown 代码块包裹的 JSON或附带解释文字本例假设提示词已要求纯 JSON如果生产中发现格式偏差需要在解析前做清理。data.get(category)用.get而非data[category]因为模型可能返回合法 JSON 但缺少category键。4.2mock_client.py——模拟客户端模拟 OpenAI 客户端返回可控的响应对象或抛出异常。fromtypesimportSimpleNamespacedefmake_response(content:str|None):构造与 openai SDK 响应结构一致的简单对象。messageSimpleNamespace(contentcontent)choiceSimpleNamespace(messagemessage)returnSimpleNamespace(choices[choice])classMockChatCompletions:def__init__(self,effects:list):self._effectseffects self._index0self.call_count0defcreate(self,**kwargs):self.call_count1ifself._indexlen(self._effects):raiseRuntimeError(Mock effects exhausted)effectself._effects[self._index]self._index1ifisinstance(effect,Exception):raiseeffectreturneffectclassMockOpenAIClient:def__init__(self,effects:list): effects: 列表每项可以是 make_response(...) 的结果或一个 Exception 实例。 按顺序在每次 create 调用时消费。 self.chatSimpleNamespace(completionsMockChatCompletions(effects))模拟客户端的结构刻意模仿openai.OpenAI的访问路径client.chat.completions.create。SimpleNamespace足以提供属性访问不需要继承真实类也不需要安装 openai 包才能运行测试classifier.py只用Any做类型提示不导入 openai。effects列表按顺序消费允许同一个测试中模拟“先失败后成功”的重试场景。这是模拟客户端相对于简单return_value的优势。4.3conftest.py与requirements.txt# conftest.pyimportsysimportos sys.path.insert(0,os.path.dirname(__file__))# requirements.txt pytest8.04.4test_classifier.py——验收测试工单分类业务逻辑的验收测试。全部使用模拟客户端不发起网络请求。importpytestfromclassifierimportclassify_ticketfrommock_clientimportMockOpenAIClient,make_responseclassTestNormalPath:正常场景模型返回合法分类程序正确提取。pytest.mark.parametrize(category, raw_content,[(billing,{category: billing}),(technical,{category: technical}),(account,{category: account}),(other,{category: other}),],)deftest_valid_categories(self,category,raw_content):clientMockOpenAIClient([make_response(raw_content)])resultclassify_ticket(client,测试工单文本)assertresultcategorydeftest_client_called_with_expected_model(self):clientMockOpenAIClient([make_response({category: billing})])classify_ticket(client,测试,modelgpt-4o-mini)assertclient.chat.completions.call_count1classTestBoundary:边界场景模型输出格式异常但调用成功。deftest_empty_content_falls_back(self):clientMockOpenAIClient([make_response()])assertclassify_ticket(client,测试)otherdeftest_whitespace_only_falls_back(self):clientMockOpenAIClient([make_response( \n )])assertclassify_ticket(client,测试)otherdeftest_non_json_content_falls_back(self):clientMockOpenAIClient([make_response(这个工单应该属于计费类))assertclassify_ticket(client,测试)otherdeftest_missing_category_key_falls_back(self):clientMockOpenAIClient([make_response({label: billing})])assertclassify_ticket(client,测试)otherdeftest_category_not_in_allowed_set_falls_back(self):clientMockOpenAIClient([make_response({category: unknown})])assertclassify_ticket(client,测试)otherdeftest_null_category_falls_back(self):clientMockOpenAIClient([make_response({category: null})])assertclassify_ticket(client,测试)otherclassTestFailure:失败场景客户端调用本身抛出异常。deftest_client_raises_exception_falls_back(self):clientMockOpenAIClient([RuntimeError(connection timeout)])assertclassify_ticket(client,测试)otherdeftest_effects_exhausted_raises_and_falls_back(self):clientMockOpenAIClient([make_response({category: billing})])classify_ticket(client,第一次调用)assertclassify_ticket(client,第二次调用)other五、运行方式与预期输出在项目目录下执行cdclassifier_demo pipinstall-rrequirements.txt python-mpytest test_classifier.py-v预期输出实际执行结果test_classifier.py::TestNormalPath::test_valid_categories[billing-...] PASSED test_classifier.py::TestNormalPath::test_valid_categories[technical-...] PASSED test_classifier.py::TestNormalPath::test_valid_categories[account-...] PASSED test_classifier.py::TestNormalPath::test_valid_categories[other-...] PASSED test_classifier.py::TestNormalPath::test_client_called_with_expected_model PASSED test_classifier.py::TestBoundary::test_empty_content_falls_back PASSED test_classifier.py::TestBoundary::test_whitespace_only_falls_back PASSED test_classifier.py::TestBoundary::test_non_json_content_falls_back PASSED test_classifier.py::TestBoundary::test_missing_category_key_falls_back PASSED test_classifier.py::TestBoundary::test_category_not_in_allowed_set_falls_back PASSED test_classifier.py::TestBoundary::test_null_category_falls_back PASSED test_classifier.py::TestFailure::test_client_raises_exception_falls_back PASSED test_classifier.py::TestFailure::test_effects_exhausted_raises_and_falls_back PASSED 13 passed in 0.08s耗时不到 0.1 秒全程无网络请求。如果去掉-vpytest 只显示一行13 passed适合在 CI 中快速反馈。六、可操作的验收与测试上文的测试文件本身就是验收标准。下表给出每个场景的判定依据你可以用这张表检查自己的模拟客户端是否覆盖了关键分支测试目的输入或操作预期结果判定方法合法分类被正确提取模型返回{category: billing}返回billingassert result billing空内容兜底模型返回返回other调用后结果等于other非 JSON 兜底模型返回自然语言文本返回other调用后结果等于other缺字段兜底模型返回{label: billing}返回other调用后结果等于other非法标签兜底模型返回{category: urgent}返回other调用后结果等于other调用异常兜底客户端 create 抛出RuntimeError返回other不向上传播调用后结果等于other无异常抛出调用次数验证调用classify_ticket一次create 被调用 1 次client.chat.completions.call_count 1边界说明test_null_category_falls_back验证的是{category: null}的情况。json.loads后data.get(category)返回NoneNone in ALLOWED_CATEGORIES为False因此走兜底。这个分支与“键不存在”是不同的输入但预期行为相同。七、常见问题与定位方法模拟对象缺少属性导致 AttributeError。模拟客户端必须模拟出业务代码实际访问的属性路径。本例中业务访问client.chat.completions.create三层属性缺一不可。如果报错说MockOpenAIClient没有chat检查MockOpenAIClient.__init__是否正确设置了self.chat。effects 耗尽后继续调用。模拟客户端的create在 effects 用完后抛RuntimeError(Mock effects exhausted)。业务代码的except Exception会捕获它并返回other所以测试不会因此报错但call_count会超出预期。建议在断言 call_count 的测试中显式检查 effects 数量是否匹配。测试通过但生产环境格式不符。模拟客户端返回的 JSON 是你自己写的格式与真实模型输出可能有差异。真实模型可能返回json ... 包裹的内容或在 JSON 前后附加说明。如果生产中发现格式偏差需要在classifier.py 的解析逻辑中加入清理步骤然后为这个新分支增加边界测试。模拟通过不保证真实模型输出格式兼容。八、验证状态已实际执行在 Python 3.11 环境中运行python -m pytest test_classifier.py -v13 个测试全部通过耗时 0.08 秒。pytest 版本 8.3.4未安装 openai 包模拟客户端不依赖它。未执行的真实服务验证未调用任何真实模型 API未验证模拟响应的结构是否与某个具体版本的 openai SDK 完全一致。如果需要做真实集成验证应将MockOpenAIClient替换为openai.OpenAI()设置OPENAI_API_KEY用相同的测试用例跑一遍观察真实响应的choices[0].message.content是否与模拟结构一致。这一步需要读者的账号和密钥本文无法代劳。