agent学习Day10——单元测试体系化与mock隔离
单元测试体系化与 Mock 隔离一、为什么要把散装脚本收编成 pytest散装脚本的四个毛病阶段1 那些验证脚本scripts/test_llm.py、llm_playground.py之类是典型的写完就丢式验证每个脚本自己TestClient(app)连一遍靠print加肉眼看结果加一个新脚本就得记住一条新命令。文件一多四个毛病暴露出来重复建 client / app——每个脚本各自建跑一次建一次浪费且状态不一致。没有统一断言语言——靠print 人眼判断过没过没法一条命令给出确定结论。不能自动发现——手动python scripts/test_xxx.py加脚本就得记新命令没法一条命令跑全部。没法复用前置——数据库、配置、client 这些测试前要准备好的东西每个脚本重写一遍。所以我引入pytest用四件套解决自动发现test_*.py、用assert当统一的过/挂判官、fixture把前置准备抽出来复用、conftest.py让 fixture 全局共享把散装验证收编成一条命令重复跑、共享前置、结果可判定的测试体系。二、pytest 核心概念与落地assert 是什么assert是 Python 内建的断言语句——“我断定这个条件必须为真否则程序当场报错”assertresp.status_code200运行时先算的布尔值为True啥也不发生为False立刻抛AssertionError测试直接挂掉并打印表达式。pytest 不发明断言它只接管AssertionError——一旦失败就标记FAILED并展示期望 vs 实际。注意assert后面跟空/假值也算失败。比如assert resp.json()[data]在data是[]或None时也挂。所以断言要写具体条件 200、is not None别只写存在。fixture 是什么、为什么不是类fixture本质是个被pytest.fixture装饰的函数不是类专门生产测试前置资源谁要在测试参数里写它名字pytest 就自动把返回值传进来# tests/conftest.pyimportpytestfromfastapi.testclientimportTestClientfromapp.mainimportapppytest.fixturedefclient():returnTestClient(app)pytest.fixture声明这是个 fixture函数体return TestClient(app)就是生产前置资源——一个绑好真实app的测试客户端放conftest.py全局共享所有tests/下的测试都能直接用不用每个文件 import测试里把 fixture 名写进参数pytest 自动注入# tests/test_health.pydeftest_health(client):# ← 签名写 clientpytest 自动注入responseclient.get(/health)assertresponse.status_code200assertresponse.json()[app_name]Test Appclient凭什么不用自己建就因为test_health的函数签名写了clientpytest 看到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:responseclient.get(/health)assertresponse.status_code200assertresponse.json()[app_name]Test Appfinally: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_codeHTTP 状态码和.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。responseclient.post(/api/v1/jd/analyze-basic,json{jd_text:jd_text})assertresponse.status_code200dataresponse.json()assertdata[word_count]len(jd_text)assertdata[has_python]isTrueassertdata[has_ai_keyword]isTrueassertaiindata[keywords]deftest_analyze_basic_missing_jd_text(client):responseclient.post(/api/v1/jd/analyze-basic,json{})assertresponse.status_code422# 缺必填字段 → Pydantic 自动 422deftest_analyze_basic_empty_jd_text(client):responseclient.post(/api/v1/jd/analyze-basic,json{jd_text:})assertresponse.status_code200dataresponse.json()assertdata[word_count]0# 空串合法但无意义边界钉死deftest_analyze_basic_long_jd_text(client):jd_textPython *1000responseclient.post(/api/v1/jd/analyze-basic,json{jd_text:jd_text})assertresponse.status_code200dataresponse.json()assertdata[word_count]len(jd_text)# 超长输入不炸长度边界稳四个场景正常请求字段校验、缺字段422、空串合法且值为 0、超长长度边界。testpaths限定扫描范围第一次跑pytest -v直接Interrupted: 2 errors during collection——pytest 连有哪些测试都没理完就崩了。原因scripts/test_analyze_jd.py和scripts/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_jd是async 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.toml的filterwarnings加ignore::StarletteDeprecationWarning嫌麻烦直接ignore::DeprecationWarning一把梭。模块限定写法ignore::DeprecationWarning:starlette匹配规则比较拧巴、不一定稳简单方案更省心。三、为什么需要 mock 隔离 LLM测试里能不能真调 LLMLlmService.analyze_jd会真调 DeepSeek。给它写测试时面临一个矛盾既要验证清洗/解析逻辑又不想真发请求。原因很硬花钱——跑 10 个用例就是 10 次 API 调用慢——网络往返动辄几秒测试套件会拖成几分钟最致命结果不稳定——同一段 prompt模型两次可能吐出不同 JSON你的assert result.job_title Python工程师时过时挂测试失去确定性。而一套好测试的本质是快、便宜、确定、能离线跑。真调 LLM 四条全违。解决办法就是用假数据顶替真调用——mock。mock 是什么mock 在测试里用一个假对象替换掉真实的外部依赖。这个假对象完全按你设定返回固定结果不干真实的事不发网络、不读库、不花钱。Python 里用unittest.mock的patch临时替换目标测试结束自动还原。比喻拍电影用替身。真实主演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_jd、call_llm、parse_jd_analysis。这些才是测试要验的。⚠错误反例patch(...LlmService.analyze_jd, new_callableAsyncMock)把整个被测函数换成假货等于测空气业务逻辑一行没验。本质区别下沉到create 只换原料文本。从拿到文本之后清洗/解析/校验全真实执行。你测的是加工过程对不对。mock 整层analyze_jd 替换整个工厂。拼 prompt、create、清洗、解析、校验全跳过只剩一个直接吐预设答案的假开关。后果analyze_jd的实现哪怕写错了比如clean_json_response有 bug、keywords字段映射写反测试照样绿因为实现根本没执行——它对该函数的任何错误完全免疫。所以不是输出假是测试丧失发现 bug 的能力。下沉 只换原料mock 整层 跳过加工。想验加工对不对只能用下沉。命名即意图测路由集成叫test_route_*测逻辑叫test_analyze_jd_*。前置知识patch 与 AsyncMockpatch是临时替换器两种写法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:...# 这一段替换生效AsyncMock是Mock的子类专门对付异步函数await x()要求x()返回协程普通Mock的return_value是普通值await会报TypeError。AsyncMock的return_value就是await之后拿到的东西。mock 隔离 LLM 的测试落地importpytestfromunittest.mockimportAsyncMock,MockfrompydanticimportValidationErrorfromapp.core.configimportSettingsfromapp.services.llm_serviceimportLlmServicepytest.fixturedefllm_service():# Settings 必填项用假值create 马上被 mock 掉不会真连网络settingsSettings(DEEPSEEK_API_KEYtest-key,DEEPSEEK_BASE_URLhttp://test.local)returnLlmService(settings)def_make_fake_response(content:str)-Mock:造一个长得像 OpenAI 返回对象的假 response。respMock()resp.choices[Mock(messageMock(contentcontent))]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.createAsyncMock(return_value_make_fake_response(json_str))resultawaitllm_service.analyze_jd(招聘Python工程师)assertresult.job_titlePython工程师assertresult.required_skills[Python,FastAPI]llm_service.client.chat.completions.create.assert_awaited_once()# 证明走的是 mockasyncdeftest_analyze_jd_strips_code_fence(llm_service):fencedjson\n{job_title: Go工程师, required_skills: [Go], responsibilities: [后端开发], keywords: [并发], difficulty: 中等}\nllm_service.client.chat.completions.createAsyncMock(return_value_make_fake_response(fenced))resultawaitllm_service.analyze_jd(招聘Go工程师)assertresult.job_titleGo工程师# 围栏被 clean_json_response 剥掉并解析asyncdeftest_analyze_jd_invalid_json_raises(llm_service):llm_service.client.chat.completions.createAsyncMock(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 用自动发现、assert、fixture、conftest四件套把散装脚本收编成一条命令跑全量、共享前置、结果可判定的测试体系。mock 只在create这个网络边界换上假数据analyze_jd、call_llm、parse_jd_analysis全程真实执行——这样测试才真正在验业务逻辑而不是给假数据盖章。「下沉网络边界、不 mock 自身」这条底线以后写任何 LLM / Agent 测试都成立。