Spring AI + Testcontainers 写 AI 应用的集成测试
Spring AI + Testcontainers 写 AI 应用的集成测试
大约八个月前,我们 AI 项目的测试策略是这样的:用 Mockito mock 掉所有的 ChatModel,单元测试全绿,PR 合并,上生产,然后收到用户反馈说「AI 的回答怎么变了」。
那是我们第一次在生产上遇到 prompt 相关的回归问题。
问题的根源是:我改了一个 system prompt 的措辞,单元测试全部 mock,当然全绿。但实际上模型的行为变了,某一类问题的回答质量明显下降。这个变化只有在真实调用模型的时候才能发现。
从那以后,我们把 AI 应用的测试策略做了重大调整,引入了 Testcontainers + Ollama 做集成测试。这篇文章就写这套方案是怎么搭的,以及我学到的一些教训。
为什么不能只靠 Mock
先把这个问题说透,不然很多人会觉得「Mock 不是挺好用的吗」。
Mock 解决的是隔离问题,不是验证问题
用 Mockito mock ChatModel,你预定义了模型的返回值。这在验证「给定模型输出 X,业务逻辑应该做 Y」的场景下是合理的。
但 AI 应用里很多东西是「prompt 驱动」的——你改了 prompt,或者改了上下文拼装逻辑,实际行为变了,但 Mock 感知不到。
你 mock 不掉的东西
- Structured Output 的解析逻辑(模型输出 markdown JSON,你的代码崩了)
- 流式响应的处理(流拼接逻辑有 bug,但 Mock 里返回的是完整字符串)
- Advisor 链对请求的修改(修改了 QuestionAnswerAdvisor 的过滤逻辑,Mock 不知道)
- Token 超限的处理(Mock 里的响应永远不会 token 超限)
测试通过但生产失败的真实案例
我们有一次修改了 TokenTextSplitter 的分块参数,chunk 大小从 512 改到了 1024。单元测试全绿(因为全部 mock),推上生产后发现:有些查询的上下文长度超过了模型的 context window,模型开始返回错误。
如果我们有基于真实模型的集成测试,这个问题会在 CI 阶段就被发现。
测试层次结构
三层结构:
- 单元测试(70%):Mock ChatModel,测试业务逻辑、数据处理、异常处理。运行快,开发阶段高频执行。
- 集成测试(20%):用 Testcontainers 启动 Ollama,用真实(但轻量)的本地模型测试完整流程。CI 里每次 PR 都跑。
- 端到端测试(10%):连接真实的 GPT-4 / Claude,验证核心场景。不频繁运行,只在重要功能变更时跑,有 API 费用。
Testcontainers + Ollama 的配置
先加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
<scope>test</scope>
</dependency>Spring AI 1.0 提供了 OllamaContainer 的官方支持(在 spring-ai-spring-boot-testcontainers 模块里),但我们也可以用通用的 Testcontainers 方式,更灵活:
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
public abstract class BaseAIIntegrationTest {
// 用 static 确保容器在所有测试类间复用,避免每次都拉镜像启动容器
@Container
static GenericContainer<?> ollamaContainer = new GenericContainer<>("ollama/ollama:latest")
.withExposedPorts(11434)
.withCommand("serve")
.waitingFor(
Wait.forHttp("/api/tags")
.withStartupTimeout(Duration.ofMinutes(3))
)
// 挂载本地模型缓存目录,避免每次重新下载
.withFileSystemBind(
System.getProperty("user.home") + "/.ollama/models",
"/root/.ollama/models",
BindMode.READ_WRITE
);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
String ollamaBaseUrl = "http://" + ollamaContainer.getHost()
+ ":" + ollamaContainer.getMappedPort(11434);
registry.add("spring.ai.ollama.base-url", () -> ollamaBaseUrl);
registry.add("spring.ai.ollama.chat.options.model", () -> "qwen2:1.5b");
registry.add("spring.ai.ollama.chat.options.temperature", () -> "0.0");
// temperature=0 让模型输出更确定,测试更稳定
}
@BeforeAll
static void pullModel() {
// 确保测试用的模型已经下载
// 这里用 qwen2:1.5b 是因为它小(约 900MB),适合 CI 环境
try {
ollamaContainer.execInContainer("ollama", "pull", "qwen2:1.5b");
} catch (Exception e) {
// 如果模型已经在缓存里,这一步会很快
System.out.println("模型可能已经缓存: " + e.getMessage());
}
}
}为什么选 qwen2:1.5b 而不是更大的模型?
- 大小约 900MB,CI 环境可以接受
- 速度够快,每个测试用例 2~5 秒
- 虽然能力比不上 GPT-4,但足够验证流程正确性
- 中文能力还过得去(我们是中文业务系统)
测试配置文件
# src/test/resources/application-test.yml
spring:
# 禁用生产环境的 AI 配置,用 Testcontainers 动态注入
ai:
openai:
api-key: "test-key-not-used" # 集成测试不走 OpenAI
ollama:
base-url: "http://localhost:11434" # 会被 DynamicPropertySource 覆盖
chat:
options:
model: "qwen2:1.5b"
# 测试用内存数据库
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL
driver-class-name: org.h2.Driver
# 测试用 pgvector(也用 Testcontainers)
# 或者换成 SimpleVectorStore(内存向量存储,不需要额外容器)
logging:
level:
org.springframework.ai: DEBUG
# 集成测试时打开 AI 日志,方便排查写一个真实的集成测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class ChatServiceIntegrationTest extends BaseAIIntegrationTest {
@Autowired
private ChatService chatService;
@Autowired
private ChatClient chatClient;
/**
* 测试基本的对话流程
* 注意:这个测试不验证回答的具体内容,只验证流程通路正常
*/
@Test
@DisplayName("基本对话流程 - 应该返回非空响应")
void testBasicChatFlow() {
String response = chatService.chat(
"test-user-001",
"conv-001",
"你好,请用中文简单回答:1+1等于几?"
);
assertThat(response).isNotNull();
assertThat(response).isNotBlank();
assertThat(response.length()).isGreaterThan(0);
// 验证回答里包含关键信息(宽松验证,不要太精确)
assertThat(response).containsAnyOf("2", "二", "两");
}
/**
* 测试 Structured Output 流程
* 这个测试验证的是:给定 prompt,模型是否能输出我们能解析的 JSON 格式
*/
@Test
@DisplayName("Structured Output 流程 - 应该成功解析为 Java 对象")
void testStructuredOutputFlow() {
BeanOutputConverter<SimpleResult> converter =
new BeanOutputConverter<>(SimpleResult.class);
String prompt = """
请提取以下信息并返回 JSON 格式:
姓名:老张,年龄:35
""" + converter.getFormat();
String raw = chatClient.prompt()
.user(prompt)
.call()
.content();
// 关键验证:能解析不崩就算通过
assertThatCode(() -> {
String cleaned = cleanMarkdown(raw);
SimpleResult result = converter.convert(cleaned);
assertThat(result).isNotNull();
}).doesNotThrowAnyException();
}
/**
* 测试流式响应流程
*/
@Test
@DisplayName("流式响应流程 - 应该正确接收并拼接流式内容")
void testStreamingFlow() {
Flux<String> stream = chatClient.prompt()
.user("请用三句话介绍 Java 语言")
.stream()
.content();
// 收集所有流式内容
List<String> chunks = stream.collectList().block(Duration.ofSeconds(30));
assertThat(chunks).isNotNull();
assertThat(chunks).isNotEmpty();
String fullResponse = String.join("", chunks);
assertThat(fullResponse).isNotBlank();
assertThat(fullResponse.length()).isGreaterThan(10);
}
/**
* 测试多轮对话的上下文保持
* 这是 Mock 测不出来的场景
*/
@Test
@DisplayName("多轮对话 - 后续问题应该能引用前面的上下文")
void testMultiTurnConversation() {
String conversationId = "test-conv-" + System.currentTimeMillis();
// 第一轮:告诉模型一个信息
chatService.chat("test-user", conversationId, "我的名字是张三,请记住");
// 第二轮:问一个需要记住第一轮信息的问题
String response = chatService.chat("test-user", conversationId, "我叫什么名字?");
// 验证模型记住了名字(宽松验证)
assertThat(response.toLowerCase()).containsAnyOf("张三", "zhangsan", "张");
}
@Data
static class SimpleResult {
private String name;
private Integer age;
}
private String cleanMarkdown(String raw) {
if (raw == null) return null;
String cleaned = raw.trim();
if (cleaned.startsWith("```json")) cleaned = cleaned.substring(7).trim();
else if (cleaned.startsWith("```")) cleaned = cleaned.substring(3).trim();
if (cleaned.endsWith("```")) cleaned = cleaned.substring(0, cleaned.length() - 3).trim();
return cleaned;
}
}RAG 流程的集成测试
这个是最有价值的,也是 Mock 根本替代不了的:
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class RAGIntegrationTest extends BaseAIIntegrationTest {
@Autowired
private VectorStore vectorStore;
@Autowired
private KnowledgeQueryService queryService;
@BeforeEach
void setupKnowledge() {
// 向量化一些测试文档
List<Document> docs = List.of(
new Document("Spring AI 是 Pivotal 开发的 Java AI 框架,支持多种大语言模型",
Map.of("source", "test-doc-1")),
new Document("Testcontainers 是一个 Java 测试库,提供轻量级的临时 Docker 容器",
Map.of("source", "test-doc-2")),
new Document("RAG 是 Retrieval-Augmented Generation 的缩写,结合检索和生成能力",
Map.of("source", "test-doc-3"))
);
vectorStore.add(docs);
}
@AfterEach
void cleanupVectors() {
// 清理测试数据,避免影响其他测试
// 具体实现取决于你用的向量数据库
}
@Test
@DisplayName("RAG 查询 - 应该基于知识库内容回答")
void testRAGQuery() {
String answer = queryService.query(
"Spring AI 是什么框架?是哪个公司开发的?",
"test-dept",
"test-user"
);
// 验证回答中包含知识库里的信息
assertThat(answer).satisfiesAnyOf(
a -> assertThat(a).containsIgnoringCase("Pivotal"),
a -> assertThat(a).containsIgnoringCase("Java"),
a -> assertThat(a).containsIgnoringCase("Spring")
);
}
@Test
@DisplayName("RAG 查询 - 不相关问题不应该编造答案")
void testRAGWithNoRelevantDocs() {
String answer = queryService.query(
"请问火星的直径是多少公里?",
"test-dept",
"test-user"
);
// 验证模型不会基于不相关内容瞎编,应该说找不到
// 这个验证比较松散,主要看系统不崩
assertThat(answer).isNotNull().isNotBlank();
}
}CI 集成的注意事项
Docker 镜像缓存
在 CI(比如 GitHub Actions)里,每次都重新拉取 Ollama 镜像和下载模型,会慢到不可接受。解法是用 layer caching:
# .github/workflows/test.yml
- name: Cache Ollama models
uses: actions/cache@v3
with:
path: ~/.ollama/models
key: ollama-qwen2-1.5b-v1
restore-keys: ollama-超时配置
Ollama 启动 + 模型加载需要时间,要把测试超时配置得宽裕一些:
// JUnit 5 超时配置
@Timeout(value = 5, unit = TimeUnit.MINUTES)
class RAGIntegrationTest {
// ...
}并发测试隔离
多个集成测试同时运行,共享同一个 Ollama 容器。要注意向量数据的隔离,用唯一的 conversationId 或者在 @BeforeEach/@AfterEach 里做清理。
我在测试策略上的错误判断
回头看,我当时有几个错误判断:
错误一:「AI 应用不需要集成测试,因为模型输出是不确定的」
这个逻辑有问题。测试不是要验证模型输出的精确内容,而是验证系统的流程、边界处理、异常处理。这些都是确定的。
错误二:「集成测试太慢,影响开发效率」
确实慢,但我们做了几件事来改善:
- 用小模型(qwen2:1.5b),单个测试 2~5 秒
- 只在 PR CI 里跑,本地开发默认不跑集成测试
- 通过 Maven profile 区分:
mvn test只跑单元测试,mvn verify跑全部
错误三:「Mock 能覆盖所有场景」
不能。上面说的那些 Mock 覆盖不到的场景,都是生产中踩过的坑。
推荐的测试策略
总结成一句话:单元测试验证业务逻辑,集成测试验证 AI 流程,不要把两件事混在一起用 Mock 一勺烩。
具体做法:
ChatModel、VectorStore这些 AI 基础设施,在集成测试里用真实实例(Ollama)- 业务逻辑(路由规则、数据处理、权限判断)在单元测试里用 Mock
- 关键的 end-to-end 场景(核心业务流、主要用户旅程)用真实模型偶发测试
这套策略在我们项目里跑了大半年,生产上的 AI 相关 bug 明显减少了。
