AI 应用的单元测试怎么写——Mock 还是真实调用,我的判断
AI 应用的单元测试怎么写——Mock 还是真实调用,我的判断
适读人群:有基础测试经验的 AI 应用开发者 | 阅读时长:约 12 分钟 | 核心价值:建立一套可落地的 AI 应用测试策略,不再纠结 Mock 还是真实调用
上个月,我手头一个 AI 项目的测试套件跑了 40 分钟。
40 分钟。CI 每次提交都要等这么久,团队里的人直接开始摸鱼。我去看了一眼代码,发现问题出在哪:前一个同学写测试的时候,把所有涉及 LLM 的地方都用真实 API 调用了,理由是"Mock 的话不准确"。
这个出发点没错,但做法完全走偏了。
我花了两天把测试套件重构了一遍,从 40 分钟压到 3 分钟,同时测试覆盖率从 42% 提升到 71%。这篇文章把我的整个判断框架写出来。
先把问题说清楚:AI 应用测试为什么特别难
传统应用测试有一个基本假设:相同的输入,相同的输出。你 Mock 一个数据库查询,返回固定数据,测试结果可预期。
AI 应用打破了这个假设。同一个 Prompt,不同时间调用 GPT-4,你可能得到不同的回答——不是 bug,这是特性。温度、模型版本更新、随机性,都会影响输出。
这带来了几个真实的痛点:
成本问题。 你有 200 个测试用例,每个用例都调用 GPT-4,每次跑完测试花你两块钱。一天跑 10 次,一个月下来 600 块。一个中型项目,这个数字会更大。
稳定性问题。 测试套件应该是确定性的。你的代码没改,测试却因为 LLM 今天"心情不好"输出了不一样的格式而失败——这不是测试,这是彩票。
速度问题。 LLM API 调用本质上是网络请求,延迟在 1-5 秒之间很正常。200 个测试用例,就算每个 2 秒,也要 400 秒。
但另一方面,Mock 也不是没有代价的。Mock 和真实行为之间的漂移是真实存在的风险。你 Mock 了 OpenAI 的返回格式,但 OpenAI 悄悄更新了 API,你的 Mock 还是老样子,测试一直通过,上了生产才爆炸。
所以,不是"用 Mock"还是"不用 Mock"的问题,而是在正确的地方用正确的策略。
我的判断框架:两个维度,四个象限
我用两个维度来判断一个测试场景应该怎么处理:
- 测什么:业务逻辑 vs LLM 能力
- 验什么:确定性结果 vs 非确定性结果
确定性结果 非确定性结果
+------------------+------------------+
业务逻辑 | 必须 Mock | 必须 Mock |
| (快、稳、省钱) | (行为可控) |
+------------------+------------------+
LLM 能力 | 可以 Mock | 必须真实调用 |
| (验格式/结构) | (但要隔离) |
+------------------+------------------+业务逻辑测试 → 全部 Mock LLM
你在测"当 LLM 返回一个分类结果时,系统是否正确路由到对应处理器",这里 LLM 是基础设施,不是被测对象。Mock 它,给它一个确定的返回值,专注测路由逻辑。
LLM 能力测试 → 谨慎使用真实调用,但要隔离
你在测"这个 Prompt 能不能稳定地从非结构化文本里提取日期",这里 LLM 本身是被测对象。这类测试需要真实调用,但应该放在独立的测试集里,不要和单元测试混在一起,不要在每次提交时都跑。
实战代码:Mock 的正确写法
我用 Python + pytest 的例子,因为现在大部分 AI 应用都在这个生态里。
场景 1:测业务逻辑,Mock LLM 返回
假设你有一个客服意图分类器,根据 LLM 的分类结果路由到不同处理器:
# intent_router.py
from openai import OpenAI
from enum import Enum
class Intent(Enum):
COMPLAINT = "complaint"
INQUIRY = "inquiry"
REFUND = "refund"
OTHER = "other"
class IntentRouter:
def __init__(self, client: OpenAI):
self.client = client
def classify(self, user_message: str) -> Intent:
response = self.client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Classify the user's intent. Return ONLY one word: complaint, inquiry, refund, or other."
},
{"role": "user", "content": user_message}
],
temperature=0
)
raw = response.choices[0].message.content.strip().lower()
try:
return Intent(raw)
except ValueError:
return Intent.OTHER
def route(self, user_message: str) -> dict:
intent = self.classify(user_message)
handlers = {
Intent.COMPLAINT: self._handle_complaint,
Intent.INQUIRY: self._handle_inquiry,
Intent.REFUND: self._handle_refund,
Intent.OTHER: self._handle_other,
}
handler = handlers.get(intent, self._handle_other)
return handler(user_message)
def _handle_complaint(self, msg): return {"type": "complaint", "priority": "high"}
def _handle_inquiry(self, msg): return {"type": "inquiry", "priority": "normal"}
def _handle_refund(self, msg): return {"type": "refund", "priority": "high"}
def _handle_other(self, msg): return {"type": "other", "priority": "low"}对应的测试,业务逻辑部分全部 Mock:
# test_intent_router.py
import pytest
from unittest.mock import MagicMock, patch
from intent_router import IntentRouter, Intent
def make_mock_response(content: str):
"""构造一个假的 OpenAI response 对象"""
mock_response = MagicMock()
mock_response.choices[0].message.content = content
return mock_response
@pytest.fixture
def mock_client():
return MagicMock()
@pytest.fixture
def router(mock_client):
return IntentRouter(client=mock_client)
class TestIntentClassification:
"""测分类逻辑——LLM 是 Mock 的,测的是 Intent 枚举转换"""
def test_classify_complaint(self, router, mock_client):
mock_client.chat.completions.create.return_value = make_mock_response("complaint")
result = router.classify("你们的产品太差了")
assert result == Intent.COMPLAINT
def test_classify_with_extra_whitespace(self, router, mock_client):
# LLM 有时会返回带空格或换行的内容
mock_client.chat.completions.create.return_value = make_mock_response(" REFUND\n")
result = router.classify("我要退款")
assert result == Intent.REFUND
def test_classify_unknown_falls_back_to_other(self, router, mock_client):
# LLM 返回了意料之外的内容,要能优雅降级
mock_client.chat.completions.create.return_value = make_mock_response("不知道")
result = router.classify("随便说点什么")
assert result == Intent.OTHER
class TestRouting:
"""测路由逻辑——直接 Mock classify 方法,专注测路由本身"""
def test_complaint_routes_to_high_priority(self, router):
with patch.object(router, 'classify', return_value=Intent.COMPLAINT):
result = router.route("任意消息")
assert result["priority"] == "high"
assert result["type"] == "complaint"
def test_inquiry_routes_to_normal_priority(self, router):
with patch.object(router, 'classify', return_value=Intent.INQUIRY):
result = router.route("任意消息")
assert result["priority"] == "normal"
def test_refund_routes_to_high_priority(self, router):
with patch.object(router, 'classify', return_value=Intent.REFUND):
result = router.route("任意消息")
assert result["priority"] == "high"注意这里的测试设计:TestRouting 里我直接 Mock 了 classify 方法,而不是 Mock OpenAI client。这样更精准——我在测路由逻辑,不是在测分类逻辑,所以把分类结果直接固定住。
场景 2:测结构化输出的解析逻辑
OpenAI 的 structured output 现在很常用,但解析这块的逻辑要单独测:
# data_extractor.py
from pydantic import BaseModel
from typing import Optional
from openai import OpenAI
import json
class ContactInfo(BaseModel):
name: str
phone: Optional[str] = None
email: Optional[str] = None
company: Optional[str] = None
class DataExtractor:
def __init__(self, client: OpenAI):
self.client = client
def extract_contact(self, text: str) -> ContactInfo:
response = self.client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": "Extract contact information from the text. Return as JSON."
},
{"role": "user", "content": text}
],
response_format={"type": "json_object"}
)
raw_json = response.choices[0].message.content
data = json.loads(raw_json)
return ContactInfo(**data)测试:
# test_data_extractor.py
import pytest
from unittest.mock import MagicMock
from pydantic import ValidationError
from data_extractor import DataExtractor, ContactInfo
@pytest.fixture
def extractor():
return DataExtractor(client=MagicMock())
def make_json_response(json_str: str):
mock = MagicMock()
mock.choices[0].message.content = json_str
return mock
class TestContactExtraction:
def test_extracts_full_contact(self, extractor):
extractor.client.chat.completions.create.return_value = make_json_response(
'{"name": "张三", "phone": "138xxxxxxxx", "email": "zhang@example.com", "company": "某某科技"}'
)
result = extractor.extract_contact("随便输入什么")
assert result.name == "张三"
assert result.phone == "138xxxxxxxx"
assert result.company == "某某科技"
def test_extracts_minimal_contact(self, extractor):
# 只有名字,其他字段应该是 None
extractor.client.chat.completions.create.return_value = make_json_response(
'{"name": "李四"}'
)
result = extractor.extract_contact("随便输入什么")
assert result.name == "李四"
assert result.phone is None
def test_raises_on_missing_required_field(self, extractor):
# name 是必填的,如果 LLM 没提取到,应该抛出异常
extractor.client.chat.completions.create.return_value = make_json_response(
'{"phone": "138xxxxxxxx"}'
)
with pytest.raises((ValidationError, KeyError)):
extractor.extract_contact("随便输入什么")
def test_handles_malformed_json(self, extractor):
# LLM 偶尔会返回格式不对的 JSON
extractor.client.chat.completions.create.return_value = make_json_response(
"抱歉,我无法提取联系人信息"
)
with pytest.raises(Exception):
extractor.extract_contact("随便输入什么")什么时候必须用真实 API 调用
有三类场景,我会坚持用真实调用:
1. Prompt 工程验证
你改了一个 Prompt,想知道它在各种边缘 case 下表现如何。这种测试不能 Mock,因为你本身就在测 Prompt 和 LLM 的交互。但这类测试应该放在单独的 tests/prompt_eval/ 目录里,不进 CI 主流程,单独手动跑或者定期跑。
# tests/prompt_eval/test_intent_prompt.py
# 这个文件不在 pytest 默认收集范围内,需要手动指定
import pytest
from openai import OpenAI
# 标记为 slow,默认跳过
pytestmark = pytest.mark.slow
@pytest.fixture(scope="module")
def real_client():
return OpenAI() # 用真实 API key
def test_complaint_detection_accuracy(real_client):
"""验证意图分类 Prompt 对投诉类消息的准确率"""
test_cases = [
("你们的产品质量太差了!", "complaint"),
("这什么垃圾服务,差评!", "complaint"),
("我对这次体验非常不满意", "complaint"),
("请问你们的营业时间是几点?", "inquiry"),
("我想退款", "refund"),
]
correct = 0
for message, expected_intent in test_cases:
response = real_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Classify intent. Return ONLY: complaint, inquiry, refund, or other."},
{"role": "user", "content": message}
],
temperature=0
)
actual = response.choices[0].message.content.strip().lower()
if actual == expected_intent:
correct += 1
accuracy = correct / len(test_cases)
# 要求至少 80% 准确率
assert accuracy >= 0.8, f"准确率 {accuracy:.0%} 低于阈值 80%"2. 第三方服务集成测试
你在测你的代码和 OpenAI API 的集成,验证你的 API 调用参数是否正确,返回格式解析是否没问题。这类测试每次上线前跑一次就够了。
3. 性能基准测试
你需要知道某个 Prompt 的平均延迟、token 消耗,这些数据只能从真实调用里得到。
测试配置:把两类测试彻底分开
我的项目结构:
tests/
├── unit/ # 全部 Mock,每次提交都跑,要求 < 30 秒完成
│ ├── test_intent_router.py
│ ├── test_data_extractor.py
│ └── test_conversation_manager.py
├── integration/ # 部分真实调用,每天跑一次
│ └── test_api_integration.py
└── prompt_eval/ # 全部真实调用,手动触发或每周跑
├── test_intent_prompt.py
└── test_extraction_prompt.pypytest.ini 配置:
[pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests requiring real API calls
prompt_eval: marks tests for prompt evaluation
# 默认只跑 unit 测试
testpaths = tests/unit
# CI 里用这个跑:pytest tests/unit -m "not slow"
# 每天定时跑:pytest tests/unit tests/integration
# 手动评估:pytest tests/prompt_evalMock 和现实脱节的问题怎么解决
这是很多人担心的问题:Mock 用久了,和真实 API 行为偏离,然后生产出问题。
我的解法是 Contract Test。定期跑一个小测试集,专门验证真实 API 的返回格式和你的 Mock 假设是否还一致:
# tests/contract/test_openai_contract.py
"""
这个测试验证:我们对 OpenAI API 返回格式的假设是否还成立
每次 OpenAI 发布重大更新后手动跑一次
"""
import pytest
from openai import OpenAI
pytestmark = pytest.mark.integration
@pytest.fixture(scope="module")
def client():
return OpenAI()
def test_chat_completion_response_structure(client):
"""验证 response 结构没有变化"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say 'test' only"}],
max_tokens=10
)
# 验证我们 Mock 里假设的字段都存在
assert hasattr(response, 'choices')
assert len(response.choices) > 0
assert hasattr(response.choices[0], 'message')
assert hasattr(response.choices[0].message, 'content')
assert isinstance(response.choices[0].message.content, str)
# 验证 usage 字段
assert hasattr(response, 'usage')
assert hasattr(response.usage, 'total_tokens')
def test_json_mode_returns_valid_json(client):
"""验证 JSON mode 确实返回合法 JSON"""
import json
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Return a JSON with key 'result'"},
{"role": "user", "content": "Say hello"}
],
response_format={"type": "json_object"}
)
content = response.choices[0].message.content
parsed = json.loads(content) # 如果不是合法 JSON 会抛异常
assert isinstance(parsed, dict)这个 Contract Test 很轻量,总共不超过 10 个用例,每次跑花不了几块钱,但能给你早预警:API 行为变了,你的 Mock 要更新了。
一个容易忽视的测试:错误处理
AI 应用里,LLM 调用失败是高概率事件。限流、网络超时、服务端报错,这些都要测。而这些恰恰是 Mock 最擅长的地方:
# test_error_handling.py
import pytest
from unittest.mock import MagicMock, patch
from openai import RateLimitError, APITimeoutError, APIConnectionError
import httpx
def make_rate_limit_error():
"""构造一个真实的 RateLimitError"""
return RateLimitError(
message="Rate limit exceeded",
response=httpx.Response(429, request=httpx.Request("POST", "https://api.openai.com")),
body={"error": {"message": "Rate limit exceeded", "type": "rate_limit_error"}}
)
class TestErrorHandling:
def test_retries_on_rate_limit(self, router, mock_client):
"""限流时应该重试,而不是直接失败"""
# 第一次调用失败,第二次成功
mock_client.chat.completions.create.side_effect = [
make_rate_limit_error(),
make_mock_response("inquiry")
]
# 假设 router 内部有重试逻辑
result = router.classify("测试消息")
assert result == Intent.INQUIRY
assert mock_client.chat.completions.create.call_count == 2
def test_falls_back_on_timeout(self, router, mock_client):
"""超时时应该返回默认值,不能让整个请求炸掉"""
mock_client.chat.completions.create.side_effect = APITimeoutError(request=MagicMock())
result = router.classify("测试消息")
# 超时应该降级到 OTHER,而不是抛异常到上层
assert result == Intent.OTHER这类测试用真实 API 根本无法稳定复现,但用 Mock 可以非常精确地测出你的错误处理逻辑有没有问题。
总结:我的实践原则
把上面的东西压缩成几条原则:
Mock LLM 的场景:
- 测业务路由、条件判断逻辑
- 测 LLM 输出的解析、格式处理
- 测错误处理、重试机制
- 测上下文管理(会话历史的拼接、截断)
用真实 API 的场景:
- Prompt 效果验证(放在独立目录,不进主 CI)
- API 集成 Contract Test(每次 API 大更新后跑)
- 性能基准(需要真实延迟和 token 数据)
工程纪律:
- 单元测试必须 < 30 秒完成
- 真实 API 测试必须能单独触发,不污染主测试流程
- 定期跑 Contract Test,防止 Mock 和现实漂移
我现在的 AI 项目,单元测试跑 2-3 分钟,覆盖率 70%+,每次提交都跑,开发体验很好。真实 API 测试放在每天凌晨的定时任务里,出问题了早上来看报告。
不完美,但够用。工程里没有完美方案,只有合适的权衡。
