单元测试体系化与 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 内建的"断言"语句——“我断定这个条件必须为真,否则程序当场报错”

assert resp.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.py
import pytest
from fastapi.testclient import TestClient
from app.main import app

@pytest.fixture
def client():
    return TestClient(app)
  • @pytest.fixture 声明这是个 fixture;
  • 函数体 return TestClient(app) 就是"生产前置资源"——一个绑好真实 app 的测试客户端;
  • conftest.py = 全局共享,所有 tests/ 下的测试都能直接用,不用每个文件 import;
  • 测试里把 fixture 名写进参数,pytest 自动注入:
# tests/test_health.py
def test_health(client):                          # ← 签名写 client,pytest 自动注入
    response = client.get("/health")
    assert response.status_code == 200
    assert response.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.py
def test_health_with_fake_settings(client):
    app.dependency_overrides[get_settings] = fake_get_settings
    try:
        response = client.get("/health")
        assert response.status_code == 200
        assert response.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 正好:

def test_analyze_basic_normal(client):
    jd_text = "招聘 Python 后端工程师,要求熟悉 FastAPI 和 AI。"
    response = client.post("/api/v1/jd/analyze-basic", json={"jd_text": jd_text})
    assert response.status_code == 200
    data = response.json()
    assert data["word_count"] == len(jd_text)
    assert data["has_python"] is True
    assert data["has_ai_keyword"] is True
    assert "ai" in data["keywords"]

def test_analyze_basic_missing_jd_text(client):
    response = client.post("/api/v1/jd/analyze-basic", json={})
    assert response.status_code == 422          # 缺必填字段 → Pydantic 自动 422

def test_analyze_basic_empty_jd_text(client):
    response = client.post("/api/v1/jd/analyze-basic", json={"jd_text": ""})
    assert response.status_code == 200
    data = response.json()
    assert data["word_count"] == 0              # 空串合法但无意义,边界钉死

def test_analyze_basic_long_jd_text(client):
    jd_text = "Python " * 1000
    response = client.post("/api/v1/jd/analyze-basic", json={"jd_text": jd_text})
    assert response.status_code == 200
    data = response.json()
    assert data["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 是临时替换器,两种写法:

from unittest.mock import patch

# 写法 A:装饰器,整个测试替换,Mock 作为参数注入
@patch("app.services.llm_service.AsyncOpenAI")
def test_xxx(mock_openai):
    ...

# 写法 B:上下文管理器,只某段逻辑替换,退出自动还原
def test_xxx():
    with patch("app.services.llm_service.AsyncOpenAI") as mock_openai:
        ...   # 这一段替换生效

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

mock 隔离 LLM 的测试落地

import pytest
from unittest.mock import AsyncMock, Mock
from pydantic import ValidationError

from app.core.config import Settings
from app.services.llm_service import LlmService

@pytest.fixture
def llm_service():
    # Settings 必填项用假值;create 马上被 mock 掉,不会真连网络
    settings = Settings(DEEPSEEK_API_KEY="test-key", DEEPSEEK_BASE_URL="http://test.local")
    return LlmService(settings)

def _make_fake_response(content: str) -> Mock:
    """造一个长得像 OpenAI 返回对象的假 response。"""
    resp = Mock()
    resp.choices = [Mock(message=Mock(content=content))]
    return resp

async def test_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 = await llm_service.analyze_jd("招聘Python工程师")
    assert result.job_title == "Python工程师"
    assert result.required_skills == ["Python", "FastAPI"]
    llm_service.client.chat.completions.create.assert_awaited_once()   # 证明走的是 mock

async def test_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 = await llm_service.analyze_jd("招聘Go工程师")
    assert result.job_title == "Go工程师"   # 围栏被 clean_json_response 剥掉并解析

async def test_analyze_jd_invalid_json_raises(llm_service):
    llm_service.client.chat.completions.create = AsyncMock(
        return_value=_make_fake_response('{"job_title": "X"}'))   # 缺必填字段
    with pytest.raises(ValidationError):
        await llm_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 测试都成立。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐