小崔推了推眼镜:"测试 AI 应用?每次都调 OpenAI API 那测试成本得上天。"白歌递来笔记本:"Mock ChatModel 和 Testcontainers——一个单元测试,一个集成测试,完美覆盖。"
测试策略与 Mock 实践
定义与作用
AI 应用的测试面临特殊挑战:API 调用有成本且不稳定,向量存储依赖外部中间件。Spring AI 的测试策略分为两层:单元测试用 @MockBean 隔离 ChatModel,集成测试用 Testcontainers 提供真实 VectorStore 环境。
核心原理
图释:单元测试关注 ChatClient 的 Advisor 链和工具调度逻辑,用 Mock 隔离 LLM;集成测试关注 ETL Pipeline 和 RAG 检索链路,用 Testcontainers 提供真实的向量数据库。
完整示例一:单元测试——Mock ChatModel
场景说明
小崔需要测试一个带工具调度的 ChatClient,验证工具是否被正确调用,但不希望真的调用 OpenAI API。
@SpringBootTest
@AutoConfigureMockMvc
public class ChatClientUnitTest {
@MockBean
private ChatModel chatModel; // Mock 底层模型,避免真实 API 调用
@Autowired
private ChatClient.Builder chatClientBuilder;
private ChatClient chatClient;
@BeforeEach
void setUp() {
chatClient = chatClientBuilder
.defaultTools(new WeatherTools()) // 真实工具对象
.build();
}
@Test
void testToolCallingFlow() {
// 1. 预设 AI 返回"工具调用"指令
AssistantMessage toolCallMessage = new AssistantMessage(
"获取天气信息",
Map.of(),
List.of(new ToolCall(
"getWeather",
"getWeather",
new HashMap<>(Map.of("city", "广州"))
))
);
ChatResponse mockResponse = new ChatResponse(
List.of(new Generation(toolCallMessage))
);
when(chatModel.call(any(Prompt.class)))
.thenReturn(mockResponse);
// 2. 执行
String result = chatClient.prompt()
.user("今天广州天气怎么样?")
.call()
.content();
// 3. 验证
assertThat(result).contains("广州");
verify(chatModel, atLeastOnce()).call(any(Prompt.class));
}
@Test
void testAdvisorModifiesPrompt() {
// 预设 AI 简单回复
AssistantMessage aiReply = new AssistantMessage(
"你好!我是飞翔科技智能助手。有什么可以帮助你?");
ChatResponse mockResponse = new ChatResponse(
List.of(new Generation(aiReply))
);
when(chatModel.call(any(Prompt.class)))
.thenAnswer(invocation -> {
Prompt actualPrompt = invocation.getArgument(0);
String userContent = actualPrompt.getInstructions().stream()
.filter(m -> m.getMessageType() == MessageType.USER)
.map(Message::getContent)
.findFirst()
.orElse("");
// 验证 Prompt 中是否被 Advisor 注入了内容
assertThat(userContent).doesNotContain("SYSTEM_INJECT");
return mockResponse;
});
String result = chatClient.prompt()
.user("测试消息")
.call()
.content();
assertThat(result).contains("飞翔科技");
}
}
运行结果
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
测试用时 < 1 秒(全部 Mock,零网络开销),相比真实 API 调用节约了时间和费用。
完整示例二:集成测试——Testcontainers + PGVector
场景说明
白歌要求验证 ETL 注入和 RAG 检索链路,确保 VectorStore 配置正确、SimilaritySearch 返回符合预期的文档片段。
依赖引入
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
public class RagIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"pgvector/pgvector:pg16"
)
.withDatabaseName("vectordb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private VectorStore vectorStore;
@Test
void testDocumentIngestionAndSearch() {
// 1. 注入测试文档
Document doc1 = new Document(
"数据结构是计算机科学的基础课程,涵盖线性表、树、图等核心概念。",
Map.of("course", "CS101", "teacher", "白歌")
);
Document doc2 = new Document(
"线性代数主要研究向量空间和线性变换,是机器学习的基础数学工具。",
Map.of("course", "MATH201", "teacher", "大翔")
);
vectorStore.add(List.of(doc1, doc2));
// 2. 语义检索
SearchRequest request = SearchRequest.query("计算机基础课程有哪些")
.withTopK(2)
.withSimilarityThreshold(0.6);
List<Document> results = vectorStore
.similaritySearch(request);
// 3. 验证
assertThat(results).isNotEmpty();
assertThat(results.get(0).getContent())
.contains("数据结构");
assertThat(results.get(0).getMetadata())
.containsEntry("course", "CS101");
}
@Test
void testMetadataFiltering() {
Document doc1 = new Document(
"机器学习实战课程", Map.of("level", "advanced")
);
Document doc2 = new Document(
"编程基础入门", Map.of("level", "beginner")
);
vectorStore.add(List.of(doc1, doc2));
SearchRequest request = SearchRequest
.query("适合新手的课程")
.withFilterExpression("level == 'beginner'")
.withTopK(1);
List<Document> results = vectorStore
.similaritySearch(request);
assertThat(results).hasSize(1);
assertThat(results.get(0).getContent())
.contains("基础入门");
}
}
运行结果
Container is started (pgvector:pg16)
[VectorStore] Added 2 documents
[SimilaritySearch] Query: "计算机基础课程有哪些"
[SimilaritySearch] Results: 2 matches
#1 [score=0.92] 数据结构是计算机科学的基础课程...
#2 [score=0.78] 线性代数主要研究向量空间...
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
测试策略总结
| 测试层次 | 工具 | Mock 对象 | 验证目标 |
|---|---|---|---|
| 单元测试 | @MockBean | ChatModel | Advisor 逻辑、工具调度 |
| 集成测试 | Testcontainers | 无(真实数据库) | ETL + 检索链路 |
| 端到端测试 | WireMock | 外部 AI API | 完整请求-响应流程 |
易错场景与面试考点
易错场景一:忘记 @MockBean 导致真实 API 调用
// ❌ 错误:没有 Mock ChatModel
@SpringBootTest
public class ChatTest {
@Autowired
private ChatClient chatClient;
@Test
void testSomething() {
// 真的调用了 OpenAI API!
chatClient.prompt().user("hello").call().content();
}
}
问题分析
测试环境没有真实的 OpenAI API Key,测试会失败。即使有,也会产生费用。
// ✅ 正确:Mock ChatModel
@SpringBootTest
public class ChatTest {
@MockBean
private ChatModel chatModel;
@Autowired
private ChatClient.Builder builder;
@Test
void testSomething() {
when(chatModel.call(any(Prompt.class)))
.thenReturn(new ChatResponse(
List.of(new Generation(
new AssistantMessage("Mock reply")))));
// 零费用、确定性测试
}
}
面试高频题
Q1:Spring AI 应用的测试策略是什么?
分层测试:单元测试 Mock ChatModel,验证 Advisor 链和工具调度;集成测试用 Testcontainers 真实 VectorStore 验证 RAG 链路;端到端测试用 WireMock 模拟外部 API 验证完整流程。核心原则是"隔离外部依赖、保真内部逻辑"。
Q2:为什么测试 RAG 要用真实 VectorStore?
VectorStore 的相似性搜索涉及 Embedding 模型调用和向量距离计算,Mock 无法验证检索结果的准确性和相关性排序。Testcontainers 提供真实数据库环境,确保 ETL 注入和检索链路在生产部署前得到充分验证。
本章小结
- @MockBean 隔离 ChatModel,零成本快速验证业务逻辑
- Testcontainers 提供真实 VectorStore 环境,保障 RAG 链路可靠性
- 分层测试策略:单元 → 集成 → 端到端
- Mock 应覆盖:Advisor 逻辑、工具调度、异常处理