Appearance
集成测试与假模型:不烧钱也能测 AI 代码
1. 本节产出
一套分层的 AI 测试:单元测试用假模型跑(0 成本、秒级),集成测试用 Testcontainers 起真实数据库验证持久化,另有一小组冒烟测试打真实厂商(每天一次,有预算上限)。mvn test 全程不花钱也能跑绿。
2. 前置依赖
- 01-06 多轮对话与 Memory 持久化:有需要测试的持久化逻辑
- 01-08 结构化输出:有需要测试的反序列化逻辑
- 本地装有 Docker(Testcontainers 依赖)
3. 为什么 AI 项目的测试必须分三层
常见的两种极端,都是错的:
极端一:所有测试都打真实模型。 后果:跑一次测试几块钱,CI 上一天几十次就是几百块;测试不稳定(模型输出随机);没有 Key 的同事跑不了;网络抖动就红灯。最终团队会关掉 CI,回归保护归零。
极端二:只测自己的代码,测模型调用全部 mock 掉。 后果:mock 说返回 A,真实模型返回 B,你的解析代码在线上崩。这类 bug 只有真跑才能发现。
正确做法:金字塔三层,每层解决不同问题。
| 层 | 用什么 | 测什么 | 成本 | 频次 |
|---|---|---|---|---|
| 单元测试 | 假 ChatModel | 你的编排逻辑、解析逻辑、分支处理 | 0 | 每次提交 |
| 集成测试 | Testcontainers + 假模型 | 数据库持久化、Advisor 顺序、配置装配 | 0 | 每次提交 |
| 冒烟测试 | 真实厂商 | 模型是否仍遵循 Schema、工具是否仍触发 | 几毛钱 | 每天一次 |
关键认知:AI 应用里真正需要测的是「模型之外的所有代码」。 模型本身不在你的测试范围内——它是外部依赖,就像数据库一样。你不会在单元测试里验证 MySQL 的查询引擎,同样不该在这里验证模型有多聪明。
4. 核心原理
4.1 假模型怎么实现
ChatModel 是一个接口,实现它就能造一个假模型:
java
public interface ChatModel {
ChatResponse call(Prompt prompt);
ChatOptions getDefaultOptions();
}只需要实现 call()。给不同的输入返回预设的 ChatResponse,就能模拟各种场景——包括正常回答、工具调用请求、超长输出、异常。
4.2 用假模型能测到什么
| 场景 | 怎么模拟 | 测什么 |
|---|---|---|
| 正常回答 | 返回固定文本 | 编排逻辑、结果处理 |
| 触发工具调用 | 返回带 ToolCall 的响应 | 工具是否被正确执行、结果是否回填 |
| 结构化输出 | 返回预设 JSON(含代码块噪声) | Converter 清洗与反序列化 |
| 上下文超限 | 抛特定异常 | 你的压缩逻辑是否触发 |
| 限流 | 抛 429 异常 | 重试与降级是否正确 |
| 慢响应 | sleep 一段时间 | 超时是否生效 |
最后三项是最有价值的——这些路径在线上会走,但靠真实模型很难稳定复现。假模型让「异常路径」变成可测试的常规路径。
4.3 Testcontainers 解决什么
单元测试:假模型 + H2 → 快,但 SQL 方言差异测不到
集成测试:假模型 + 真 PostgreSQL → 慢一点,但能测到真实建表、索引、并发用 H2 测 PostgreSQL 代码,会遇到方言不兼容:JSONB 类型、窗口函数、ON CONFLICT 语法在 H2 里行为不同。上线才发现建表失败是典型事故。Testcontainers 用真实数据库镜像,把这些差异提前暴露。
提示:Testcontainers 需要本地 Docker。Windows 上用 Docker Desktop + WSL2 后端;Linux 上直接可用。CI 上需要 DinD 或挂载 Docker socket,配置前先确认 CI 环境支持。
4.4 冒烟测试怎么控制成本
| 手段 | 做法 |
|---|---|
| 低频 | 独立 profile,CI 上每天跑一次,不进 PR 门禁 |
| 低量 | 每次最多 5 个用例,每个 maxTokens 设 50 |
| 有上限 | 单元测试里断言单次成本估算低于阈值 |
| 可跳过 | 没有 Key 时自动 skip,不失败 |
5. 代码走查
5.1 假 ChatModel
java
// src/test/java/com/example/aibasics/support/FakeChatModel.java
public class FakeChatModel implements ChatModel {
private final Function<Prompt, String> responder;
public FakeChatModel(Function<Prompt, String> responder) {
this.responder = responder;
}
/** 常用工厂:固定回答 */
public static FakeChatModel answering(String text) {
return new FakeChatModel(p -> text);
}
/** 常用工厂:回显用户最后一条消息,便于断言 */
public static FakeChatModel echo() {
return new FakeChatModel(FakeChatModel::lastUserText);
}
@Override
public ChatResponse call(Prompt prompt) {
String text = responder.apply(prompt);
return new ChatResponse(List.of(
new Generation(new AssistantMessage(text))));
}
@Override
public ChatOptions getDefaultOptions() {
return OpenAiChatOptions.builder().model("fake").temperature(0.0).build();
}
private static String lastUserText(Prompt p) {
return p.getInstructions().isEmpty() ? "" : p.getInstructions().getLast().getText();
}
}5.2 单元测试:测编排逻辑,不测模型
java
// src/test/java/com/example/aibasics/service/AssistantServiceTest.java
class AssistantServiceTest {
@Test
void 应把用户输入原样传给模型() {
var model = FakeChatModel.echo();
var client = ChatClient.builder(model).build();
var service = new AssistantService(client);
String out = service.ask("你好");
assertThat(out).isEqualTo("你好");
}
@Test
void 结构化输出应能清洗代码块标记() {
// 模拟模型返回带 ```json 的脏输出
var model = FakeChatModel.answering(
"```json\n{\"title\":\"支付超时\",\"priority\":\"HIGH\"}\n```");
var client = ChatClient.builder(model).build();
Ticket t = new TicketExtractor(client).extract("...");
assertThat(t.title()).isEqualTo("支付超时");
assertThat(t.priority()).isEqualTo(Ticket.Priority.HIGH);
}
@Test
void 模型返回非法内容时应走兜底而不是抛异常() {
var model = FakeChatModel.answering("这不是 JSON");
var client = ChatClient.builder(model).build();
Ticket t = new TicketExtractor(client).extract("...");
assertThat(t).isNotNull(); // 关键:有兜底
assertThat(t.priority()).isEqualTo(Ticket.Priority.MEDIUM);
}
}第二个用例是这节的精髓:用假模型精确复现「模型返回脏输出」这个线上高频场景,验证清洗逻辑。用真实模型反而测不了——你没法让它稳定地返回带代码块的脏数据。
5.3 集成测试:Testcontainers + 真数据库
java
// src/test/java/com/example/aibasics/memory/ChatMemoryIT.java
@Testcontainers
@SpringBootTest
class ChatMemoryIT {
@Container
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("chat")
.withUsername("sa")
.withPassword("sa");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", pg::getJdbcUrl);
r.add("spring.datasource.username", pg::getUsername);
r.add("spring.datasource.password", pg::getPassword);
}
@Autowired ChatMemory memory;
@Test
void 记忆应能跨实例读取() {
memory.add("conv-1", List.of(new UserMessage("我叫木鱼")));
List<Message> loaded = memory.get("conv-1");
assertThat(loaded).extracting(Message::getText).contains("我叫木鱼");
}
@Test
void 不同会话的记忆应互相隔离() {
memory.add("conv-a", List.of(new UserMessage("A 的内容")));
memory.add("conv-b", List.of(new UserMessage("B 的内容")));
assertThat(memory.get("conv-a")).extracting(Message::getText)
.contains("A 的内容").doesNotContain("B 的内容");
}
}第二个用例专门测「会话隔离」——这正是 01-06 里提到的越权风险点。用真数据库跑一次,能验证建表语句、字段类型、索引是否都正确。
5.4 冒烟测试:低频、有预算
java
// src/test/java/com/example/aibasics/smoke/RealModelSmokeTest.java
@EnabledIfEnvironmentVariable(named = "SMOKE_TEST", matches = "true")
@SpringBootTest
class RealModelSmokeTest {
@Autowired ChatClient chatClient;
@Test
void 模型应仍能遵循结构化输出Schema() {
var converter = new BeanOutputConverter<>(Ticket.class);
String raw = chatClient.prompt()
.user(u -> u.text("{format}\n{desc}")
.param("format", converter.getFormat())
.param("desc", "支付网关大量超时,用户无法下单"))
.options(OpenAiChatOptions.builder().temperature(0.0).maxTokens(80).build())
.call().content();
Ticket t = converter.convert(raw);
assertThat(t.priority()).isNotNull(); // 只断言结构不断言内容
}
}bash
# 只在 CI 的定时任务里跑
SMOKE_TEST=true mvn -q test -Dtest=RealModelSmokeTest6. 跑起来
bash
git checkout ch01-11-integration-test
# 1. 单元测试 + 集成测试(不花钱,首次会拉 Docker 镜像)
mvn test
# 2. 只看集成测试
mvn test -Dtest='*IT'
# 3. 冒烟测试(要花钱,按需)
SMOKE_TEST=true mvn test -Dtest=RealModelSmokeTest期望输出:
[INFO] Tests run: 24, Failures: 0, Errors: 0
[INFO] 集成测试启动 PostgreSQL 容器 ... 就绪耗时 6.2s| 检查项 | 通过标准 |
|---|---|
mvn test 无 Key 也能跑 | 全部通过,不依赖环境变量 |
| 耗时 | 单元测试 < 10s,集成测试 < 60s |
| 真实花费 | mvn test 期间账单无新增 |
| 异常路径覆盖 | 脏输出、429、超时三类各有对应用例 |
验证「不花钱」的方法:跑之前记一下厂商控制台的用量,跑完再看一次。这一步能让你确信 CI 里真的不会产生费用。
7. 生产避坑
- 不要让单元测试依赖真实模型,哪怕只有一个。一旦开了口子,用例数量会失控增长,几个月后 CI 每次跑几十块钱,然后有人会提议关掉测试。从第一天就把边界划清。
- Testcontainers 在 CI 上经常起不来,常见原因是 DinD 权限或镜像拉取慢。做法:CI 上配置镜像缓存,本地开发允许跳过(用
@EnabledIf判断 Docker 可用性)。不要让环境问题阻断本地开发。 - 假模型返回的内容过于「干净」,会掩盖解析 bug。建议专门准备几个「脏样本」:带代码块标记、带前后缀说明、字段缺失、枚举大小写不符。这些样本应该来自真实线上日志里出现过的失败案例,价值远高于凭空构造的数据。
8. 延伸与锚点
- 思考题:模型厂商升级了模型版本,你的假模型测试全绿但线上效果变差了。怎么发现?(提示:需要效果评测而不只是功能测试——答案在 02-17 评测体系与 03C-18 Agent 评测)
- 代码锚点:
git checkout ch01-11-integration-test - 下一课时:01-12 小节项目:能聊会查天气的助手
- 对应课件:L01-11 集成测试与假模型