AI 应用单元测试与 Mock 策略:如何在不消耗真实配额的情况下测试
2026-09-01 · 约 10 分钟阅读
给依赖 AI API 的应用写测试,比传统 CRUD 应用要棘手不少:真实调用大模型接口意味着要花钱、要等待、结果还不确定(同样的输入可能得到不同的输出)。如果每次跑 CI 都真实调用模型 API,不仅账单会失控,测试也会因为模型输出的随机性而变得不稳定。本文讲清楚如何设计一套靠谱的测试和 Mock 策略。
为什么不能直接在测试里调用真实 API
1. 成本失控:CI 流水线每次提交都跑测试,真实调用意味着每次 push 都在花钱,规模大了账单会很吓人
2. 速度慢:模型生成通常要几百毫秒到几十秒,测试套件跑一遍如果有几十上百个用例,CI 时间会大幅拉长
3. 结果不确定:大模型的输出即使设置了 temperature=0 也不一定完全确定性可复现,用真实输出做精确匹配的断言基本不可行
4. 网络依赖:测试环境的网络问题会导致测试结果不可靠,出现"重跑一次就过了"的诡异现象
分层测试策略
给 AI 应用设计测试金字塔,建议分三层:
┌─────────────────┐
│ 少量端到端测试 │ 真实调用API,人工/定期运行,验证集成正确性
├─────────────────┤
│ Mock 集成测试 │ 模拟API响应,验证业务逻辑处理正确性(占比最大)
├─────────────────┤
│ 纯逻辑单元测试 │ 不涉及API调用,测试Prompt构建、解析逻辑等
└─────────────────┘
大部分测试应该落在中间层——用 Mock 掉真实的 API 调用,专注验证你的业务代码是否正确处理了各种可能的响应(包括错误响应、异常格式)。
Mock 策略一:手写固定响应
最基础的做法,为常见场景手写预期的响应结构:
import pytest
from unittest.mock import patch, MagicMock
def mock_chat_completion_response(content: str):
mock_response = MagicMock()
mock_response.choices = [MagicMock(message=MagicMock(content=content))]
mock_response.usage = MagicMock(prompt_tokens=50, completion_tokens=20, total_tokens=70)
return mock_response
@patch("openai.resources.chat.completions.Completions.create")
def test_summarize_function(mock_create):
mock_create.return_value = mock_chat_completion_response("这是一段模拟的摘要内容。")
result = summarize_text("一段很长的原文……")
assert "摘要" in result
mock_create.assert_called_once()
# 验证调用参数是否符合预期,比如模型名、temperature设置
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs["model"] == "gpt-4o-mini"
Mock 策略二:录制回放(VCR / Cassette 模式)
对于比较复杂的调用链路,手写 Mock 会很繁琐。更高效的方式是先真实调用一次 API,把请求和响应"录制"下来保存成 fixture 文件,之后测试直接"回放",不再产生真实调用:
import vcr
my_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode="once", # 首次录制,之后只回放,除非删除cassette文件
filter_headers=["authorization"], # 关键:录制时过滤掉密钥,避免泄露
)
@my_vcr.use_cassette("summarize_basic.yaml")
def test_summarize_with_real_recorded_response():
result = summarize_text("一段真实的测试文本")
assert len(result) > 0
这种模式的好处是响应内容是真实API返回过的(更贴近实际情况),同时后续运行完全不产生新的真实调用和费用。需要更新测试用例时,删掉对应的 cassette 文件重新录制一次即可。
安全提醒:录制回放文件里必须过滤掉 API Key 等敏感信息,避免把密钥意外提交到代码仓库。
Mock 策略三:Mock Server(适合集成测试/E2E)
如果你想更贴近真实网络调用(测试重试、超时等网络层逻辑),可以用轻量级 Mock Server 模拟 API 端点,而不是直接 Mock 客户端函数:
import respx
import httpx
@respx.mock
def test_retry_on_rate_limit():
respx.post("https://api.example.com/v1/chat/completions").mock(
side_effect=[
httpx.Response(429, json={"error": "rate_limited"}),
httpx.Response(200, json={"choices": [{"message": {"content": "成功响应"}}]}),
]
)
result = call_with_retry("测试输入")
assert result == "成功响应"
这种方式能真实测试你的 HTTP 客户端层重试逻辑、超时处理等网络细节,比单纯 Mock 高层函数更全面。
非确定性输出怎么断言
对于必须真实调用模型的少量端到端测试(比如验证 Prompt 改动后效果没有明显退化),不能用精确字符串匹配,常见的断言思路:
1. 结构性断言:只验证输出是否符合预期的格式(JSON schema、是否包含必要字段),不关心具体文字内容
2. 关键词/语义包含断言:验证输出是否包含预期的关键信息点,而不是完全匹配
3. 用另一个模型做评估(LLM-as-judge):用一个独立的评估 Prompt,让模型判断输出是否满足某些标准(如"是否准确总结了原文的三个要点"),这种方式适合做定期的回归评估,但不建议作为每次CI必跑的强制测试(成本和稳定性都不适合)
4. 长度、格式的边界检查:比如验证生成内容长度在合理区间内,没有出现明显截断或重复
def test_summary_structure_and_bounds():
result = summarize_text(long_text) # 真实调用,用于定期回归而非每次CI
assert 50 <= len(result) <= 500
assert not result.endswith("...") # 避免截断
assert result.count("。") >= 1 # 至少是完整句子
总结
给 AI 应用写测试的核心思路是:把"调用大模型"这个不确定、有成本的操作,和"处理模型返回结果"这个确定性的业务逻辑分离测试。大部分测试应该通过 Mock/录制回放覆盖业务逻辑正确性,只保留少量真实调用的端到端测试用于定期验证集成的健康状态,而不是让每次CI都产生真实费用和不稳定的结果。
如果你正在为项目寻找计费透明、支持小额度测试调用的 AI API 中转站,方便在开发调试阶段控制成本,可以前往 openairouter.net 查看最新的平台对比与计费方式解析。