当前位置: 首页 > news >正文

agent学习Day10——单元测试体系化与mock隔离

单元测试体系化与 Mock 隔离

一、为什么要把散装脚本收编成 pytest

散装脚本的四个毛病

阶段1 那些验证脚本(scripts/test_llm.pyllm_playground.py之类)是典型的"写完就丢"式验证:每个脚本自己TestClient(app)连一遍,靠print加肉眼看结果,加一个新脚本就得记住一条新命令。文件一多,四个毛病暴露出来:

  1. 重复建 client / app——每个脚本各自建,跑一次建一次,浪费且状态不一致。
  2. 没有统一断言语言——靠print+ 人眼判断"过没过",没法一条命令给出确定结论。
  3. 不能自动发现——手动python scripts/test_xxx.py,加脚本就得记新命令,没法"一条命令跑全部"。
  4. 没法复用前置——数据库、配置、client 这些"测试前要准备好"的东西,每个脚本重写一遍。

所以我引入pytest,用四件套解决:自动发现test_*.py、用assert当统一的"过/挂"判官、fixture把"前置准备"抽出来复用、conftest.py让 fixture 全局共享,把散装验证收编成"一条命令重复跑、共享前置、结果可判定"的测试体系。

二、pytest 核心概念与落地

assert 是什么

assert是 Python 内建的"断言"语句——“我断定这个条件必须为真,否则程序当场报错”

assertresp.status_code==200

运行时先算==的布尔值:为True啥也不发生;为False立刻抛AssertionError,测试直接挂掉并打印表达式。pytest 不发明断言,它只接管AssertionError——一旦失败就标记FAILED并展示期望 vs 实际。

注意assert后面跟空/假值也算失败。比如assert resp.json()["data"]data[]None时也挂。所以断言要写具体条件== 200is not None),别只写"存在"。

fixture 是什么、为什么不是类

fixture本质是个@pytest.fixture装饰的函数(不是类),专门生产"测试前置资源",谁要在测试参数里写它名字,pytest 就自动把返回值传进来:

# tests/conftest.pyimportpytestfromfastapi.testclientimportTestClientfromapp.mainimportapp@pytest.fixturedefclient():returnTestClient(app)
  • @pytest.fixture声明这是个 fixture;
  • 函数体return TestClient(app)就是"生产前置资源"——一个绑好真实app的测试客户端;
  • conftest.py=全局共享,所有tests/下的测试都能直接用,不用每个文件 import;
  • 测试里把 fixture 名写进参数,pytest 自动注入:
# tests/test_health.pydeftest_health(client):# ← 签名写 client,pytest 自动注入response=client.get("/health")assertresponse.status_code==200assertresponse.json()["app_name"]=="Test App"

client凭什么不用自己建?就因为test_health的函数签名写了client,pytest 看到conftest.py有同名 fixture,自动调用它、把TestClient(app)传进来。阶段1 散装脚本里每行手写的client = TestClient(app),现在收编成一处。

注意app是全局单例。如果测试用app.dependency_overrides注入过假依赖(比如用fake_get_settings替换真实的get_settings;这套做法与完整定义见《依赖注入与 LLM 服务化》一文),务必在测试后用try/finally清理,否则会污染其它测试:

# tests/test_health.pydeftest_health_with_fake_settings(client):app.dependency_overrides[get_settings]=fake_get_settingstry:response=client.get("/health")assertresponse.status_code==200assertresponse.json()["app_name"]=="Test App"finally:app.dependency_overrides.clear()# app 是全局单例,必须清理

TestClient 怎么发请求

TestClient(来自fastapi.testclient,底层是httpx)让你不启动真实服务器就能调 FastAPI 接口,直接在进程内模拟一次 HTTP 请求:

client.get("/health")# GET 请求client.post("/api/v1/jd/analyze-basic",# POST 请求json={"jd_text":"招聘Python工程师"})# json= 自动序列化请求体

返回对象有.status_code(HTTP 状态码)和.json()(把响应体解析回字典)。json=参数自动把字典转成 JSON 请求体并设好Content-Type,对应路由里的 Pydantic 模型。

覆盖四场景的测试

POST /api/v1/jd/analyze-basic是确定性接口(不调 LLM,按字符串算word_count/has_python/has_ai_keyword/keywords),拿来练 pytest 正好:

deftest_analyze_basic_normal(client):jd_text="招聘 Python 后端工程师,要求熟悉 FastAPI 和 AI。"response=client.post("/api/v1/jd/analyze-basic",json={"jd_text":jd_text})assertresponse.status_code==200data=response.json()assertdata["word_count"]==len(jd_text)assertdata["has_python"]isTrueassertdata["has_ai_keyword"]isTrueassert"ai"indata["keywords"]deftest_analyze_basic_missing_jd_text(client):response=client.post("/api/v1/jd/analyze-basic",json={})assertresponse.status_code==422# 缺必填字段 → Pydantic 自动 422deftest_analyze_basic_empty_jd_text(client):response=client.post("/api/v1/jd/analyze-basic",json={"jd_text":""})assertresponse.status_code==200data=response.json()assertdata["word_count"]==0# 空串合法但无意义,边界钉死deftest_analyze_basic_long_jd_text(client):jd_text="Python "*1000response=client.post("/api/v1/jd/analyze-basic",json={"jd_text":jd_text})assertresponse.status_code==200data=response.json()assertdata["word_count"]==len(jd_text)# 超长输入不炸,长度边界稳

四个场景:正常请求(字段校验)、缺字段(422)、空串(合法且值为 0)、超长(长度边界)。

testpaths:限定扫描范围

第一次跑pytest -v直接Interrupted: 2 errors during collection——pytest 连"有哪些测试"都没理完就崩了。原因:scripts/test_analyze_jd.pyscripts/test_jd_analysis.py这两段阶段1 练习脚本名字带test_,被 pytest 默认递归扫描当成测试收进去,但它们 import 的app.services.jd_analysis早在单元1.2 被删了,import 自然炸。

修法是在pyproject.toml限定扫描范围,把正式测试和练习脚本隔开:

[tool.pytest.ini_options] testpaths = ["tests"]

asyncio_mode:让异步测试跑起来

LlmService.analyze_jdasync def,对应的测试也得是async def。但 pytest 默认不认异步测试,必须装pytest-asyncio插件并在pyproject.toml开自动模式:

[tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto"

(另:StarletteDeprecationWarning是 Starlette 1.3 + httpx 0.28 的兼容提示,无害,不挡通关。想压掉就在pyproject.tomlfilterwarningsignore::StarletteDeprecationWarning;嫌麻烦直接ignore::DeprecationWarning一把梭。模块限定写法ignore::DeprecationWarning:starlette匹配规则比较拧巴、不一定稳,简单方案更省心。)

三、为什么需要 mock 隔离 LLM

测试里能不能真调 LLM

LlmService.analyze_jd会真调 DeepSeek。给它写测试时面临一个矛盾:既要验证清洗/解析逻辑,又不想真发请求。原因很硬:

  • 花钱——跑 10 个用例就是 10 次 API 调用;
  • ——网络往返动辄几秒,测试套件会拖成几分钟;
  • 最致命:结果不稳定——同一段 prompt,模型两次可能吐出不同 JSON,你的assert result.job_title == "Python工程师"时过时挂,测试失去"确定性"。

而一套好测试的本质是:快、便宜、确定、能离线跑。真调 LLM 四条全违。解决办法就是用假数据顶替真调用——mock。

mock 是什么

mock = 在测试里用一个假对象替换掉真实的外部依赖。这个假对象完全按你设定返回固定结果,不干真实的事(不发网络、不读库、不花钱)。Python 里用unittest.mockpatch临时替换目标,测试结束自动还原。

比喻:拍电影用替身。真实主演(LLM)太贵、档期排不上,找个替身(mock)按剧本演固定动作,你真正要检验的是导演的调度逻辑(你的业务代码)对不对。替身演得标不标准不归你管——那是"主演自己剧组的活"。

调用链与 mock 点

真实的LlmService调用链分六层:

① 路由层 POST /api/v1/jd/analyze-basic-llm ↓ await llm_service.analyze_jd(jd_text) ② analyze_jd(jd_text) ← 绿色,真实跑(被测主函数) ↓ call_llm(...) ③ call_llm(prompt) ← 绿色,真实跑(拼 messages + 指数退避重试) ↓ await self.client.chat.completions.create(...) ④ create(...) ← 红色,唯一 mock 点(网络边界,花钱/慢/变异) → 返回 content: str ↓ parse_jd_analysis(raw) ⑤ parse_jd_analysis(raw) ← 绿色,真实跑(clean_json_response + json.loads + Pydantic) ↓ ⑥ 返回 JdAnalysisResult → 200
  • 红色层 ④create是唯一的 mock 点:它发 HTTP 到 DeepSeek,不可控、花钱、结果会变异。
  • 绿色层 ②③⑤全是你自己的业务代码,必须真实跑——这才是测试要证明"对"的东西。

铁律:mock 网络边界,绝不 mock 自身业务

  • 必须 mock:外部依赖 / 网络边界(④create)。
  • 绝不 mock:被测逻辑(②③⑤)——analyze_jdcall_llmparse_jd_analysis。这些才是测试要验的。
  • 错误反例patch("...LlmService.analyze_jd", new_callable=AsyncMock)把整个被测函数换成假货,等于"测空气",业务逻辑一行没验。

本质区别

  • 下沉到create= 只换"原料文本"。从拿到文本之后,清洗/解析/校验全真实执行。你测的是"加工过程对不对"。
  • mock 整层analyze_jd= 替换"整个工厂"。拼 prompt、create、清洗、解析、校验全跳过,只剩一个"直接吐预设答案"的假开关。后果:analyze_jd的实现哪怕写错了(比如clean_json_response有 bug、keywords字段映射写反),测试照样绿,因为实现根本没执行——它对该函数的任何错误完全免疫。所以不是"输出假",是"测试丧失发现 bug 的能力"。

下沉 = 只换原料;mock 整层 = 跳过加工。想验加工对不对,只能用下沉。命名即意图:测路由集成叫test_route_*,测逻辑叫test_analyze_jd_*

前置知识:patch 与 AsyncMock

patch是临时替换器,两种写法:

fromunittest.mockimportpatch# 写法 A:装饰器,整个测试替换,Mock 作为参数注入@patch("app.services.llm_service.AsyncOpenAI")deftest_xxx(mock_openai):...# 写法 B:上下文管理器,只某段逻辑替换,退出自动还原deftest_xxx():withpatch("app.services.llm_service.AsyncOpenAI")asmock_openai:...# 这一段替换生效

AsyncMockMock的子类,专门对付异步函数:await x()要求x()返回协程,普通Mockreturn_value是普通值,await会报TypeErrorAsyncMockreturn_value就是await之后拿到的东西。

mock 隔离 LLM 的测试落地

importpytestfromunittest.mockimportAsyncMock,MockfrompydanticimportValidationErrorfromapp.core.configimportSettingsfromapp.services.llm_serviceimportLlmService@pytest.fixturedefllm_service():# Settings 必填项用假值;create 马上被 mock 掉,不会真连网络settings=Settings(DEEPSEEK_API_KEY="test-key",DEEPSEEK_BASE_URL="http://test.local")returnLlmService(settings)def_make_fake_response(content:str)->Mock:"""造一个长得像 OpenAI 返回对象的假 response。"""resp=Mock()resp.choices=[Mock(message=Mock(content=content))]returnrespasyncdeftest_analyze_jd_parses_fake_response(llm_service):json_str=('{"job_title": "Python工程师", "required_skills": ["Python", "FastAPI"],'' "responsibilities": ["后端开发"], "keywords": ["AI"], "difficulty": "中等"}')llm_service.client.chat.completions.create=AsyncMock(return_value=_make_fake_response(json_str))result=awaitllm_service.analyze_jd("招聘Python工程师")assertresult.job_title=="Python工程师"assertresult.required_skills==["Python","FastAPI"]llm_service.client.chat.completions.create.assert_awaited_once()# 证明走的是 mockasyncdeftest_analyze_jd_strips_code_fence(llm_service):fenced="```json\n"+'{"job_title": "Go工程师", "required_skills": ["Go"], "responsibilities": ["后端开发"], "keywords": ["并发"], "difficulty": "中等"}'+"\n```"llm_service.client.chat.completions.create=AsyncMock(return_value=_make_fake_response(fenced))result=awaitllm_service.analyze_jd("招聘Go工程师")assertresult.job_title=="Go工程师"# 围栏被 clean_json_response 剥掉并解析asyncdeftest_analyze_jd_invalid_json_raises(llm_service):llm_service.client.chat.completions.create=AsyncMock(return_value=_make_fake_response('{"job_title": "X"}'))# 缺必填字段withpytest.raises(ValidationError):awaitllm_service.analyze_jd("招聘")

三个测试都只动了create(红层),parse_jd_analysis/analyze_jd全程真实执行——这才是有效的 mock 隔离。assert_awaited_once()是 mock 自带断言,证明create确实被await调用了一次(没漏网真调)。

四、小结

pytest 用自动发现、assertfixtureconftest四件套把散装脚本收编成一条命令跑全量、共享前置、结果可判定的测试体系。mock 只在create这个网络边界换上假数据,analyze_jdcall_llmparse_jd_analysis全程真实执行——这样测试才真正在验业务逻辑,而不是给假数据盖章。「下沉网络边界、不 mock 自身」这条底线,以后写任何 LLM / Agent 测试都成立。

http://www.jsqmd.com/news/1216010/

相关文章:

  • 2026年7月最新雷达重庆源著天街维修保养服务电话 - 亨得利钟表维修中心
  • 专注低空经济发展多年的专家杨常卫,究竟有着怎样的从业背景?
  • 无理数、仙人球、菠萝
  • 嘉准专利柔性红尾套线缆:上万次往复折弯不断线,告别每周停机换线
  • 亨得利官方钟表服务中心|官方电话和网点地址权威信息公告(2026年7月最新) - 亨得利官方
  • 报表笔记:删除数据时明明已删除再重新调用方法以刷新数据,无故导致 405,请懂的人不会再写前后端代码
  • 创业第一天,最该把钱花在哪?不是办公室而是注册域名
  • 跨省搬家寄大件真实分享,物流+包装200斤花费170
  • 天梭官方服务项目及价格查询|热线和全部网点地址权威信息通知(2026年7月最新) - 天梭服务中心
  • AI 视频处理加速:从 SVD 到 Stable Video Diffusion 的推理延迟优化与 GPU 显存管理
  • Redis--简介及配置文件详解
  • 亨得利官方钟表服务中心|官方电话和详细地址权威信息公告(2026年7月更新) - 亨得利官方
  • 天津河西私教推荐:3位资深教练帮你告别健身误区 - GrowUME
  • OpenClaw Agent实战指南
  • ElasticSearch中的索引模板详解
  • 项目性能复盘方法论:从故障 Postmortem 到可持续优化的工程文化构建
  • 2026深圳正规搬家公司全名录清单盘点:同城跨城报价明细、5大隐形收费拆解+正规服务商甄选避坑及场景适配指南 - 厚道搬家
  • SQL 窗口函数性能调优:ROWS 和 RANGE 的执行代价差异
  • 2026 年 7 月新发布:房山专业的玻璃钢离心风机制造厂家有哪些,颠覆认知:别再用传统风机了,它如何改变工业效率? - 企业官方推荐【认证】
  • 题解:洛谷 B4360 [GESP202506 四级] 画布裁剪
  • 静态线性链表
  • 宝珀中国官方售后服务中心|官方热线和门店地址权威信息声明(2026年7月最新) - 宝珀官方售后服务中心
  • 2026大模型GEO内链优化:3个传导逻辑提权重,零成本提29%收录优先级附布局表 - 曌选科技官方账号
  • 智慧民航升级刚需:为什么机场必须普及护照阅读器技术?
  • 天津河西私教TOP5:资深教练这样选才不踩坑 - GrowUME
  • 发起讨论:JXMVC开发范式,为什么AI公司需应用XML、SQL、HTML解决AI编程的“落地困境”
  • 智能小说下载器:200+网站支持,打造你的个人数字图书馆
  • 2026 年厦门公司注册流程评测?正规代办费用及避坑全指南
  • 2026年7月亮眼光亮精密钢管厂商汇总,厚壁无缝钢管/镀锌无缝钢管/精密钢管/16Mn无缝钢管,光亮精密钢管厂家推荐 - 品牌推荐师
  • 旧衣回收平台深度解析:环保变现新选择与主流服务对比 - 快递物流资讯