单元测试Mock策略测试工程实战教程

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 查看最新的平台对比与计费方式解析。

找到最适合你的 AI API 中转站

收录 125+ 服务商,按价格、模型、标签一键筛选

查看所有中转站 →