Spring AI的测试策略:单元测试、集成测试、端到端测试全覆盖
Spring AI的测试策略:单元测试、集成测试、端到端测试全覆盖
date: 2026-09-21 tags: [测试策略, 单元测试, 集成测试, Spring AI, Java, Mockito]
开篇故事:从"祈祷式部署"到"信心发布"
陈静是某医疗AI公司的后端Lead,她们的AI问诊辅助系统接入了Spring AI + GPT-4o。
2025年3月,他们一共发生了4次P1故障,原因无一例外:AI功能上线后出了问题。
- 3月5日:换了个更长的System Prompt后,所有响应都超时(Token超限)
- 3月12日:RAG召回的文档数量从3改到5,回答开始出现幻觉(上下文窗口被截断)
- 3月21日:LLM模型从gpt-3.5-turbo换到gpt-4o后,某个Tool调用的参数格式不兼容
- 3月28日:生产环境的向量数据库索引出问题,相似度检索结果全部不相关
每次发布前,团队唯一能做的就是祈祷。没有测试,没有验证,只有在冒汗中等待监控告警。
4月,陈静花了两周系统地为AI功能补写了测试。
她的测试金字塔:
- 单元测试:Mock LLM,验证逻辑分支(快,100%覆盖,3秒内)
- 集成测试:真实Ollama,验证端到端流程(慢,关键路径,3分钟内)
- 契约测试:验证LLM响应格式(中,每次模型升级必跑)
上线效果:
- 此后6个月,0次因AI功能引发的P1故障
- 发布信心大增:从"不敢发"到"随时能发"
- 新人上手速度加快:有测试的代码更容易理解
一、AI应用测试的三大挑战
1.1 不确定性:同一个问题,10次调用返回10个不同的答案
// 这样的测试是脆弱的:
@Test
void testAiAnswer() {
String answer = chatService.ask("什么是Spring AI?");
// 断言什么?每次答案都不一样!
assertEquals("Spring AI是一个Java AI框架", answer); // 必然失败
}解决策略:
不要测试AI的输出内容,要测试:
- 系统行为(是否调用了LLM?是否传了正确的参数?)
- 输出格式(是否返回了JSON?字段是否完整?)
- 边界处理(LLM返回空怎么处理?超时怎么处理?)
1.2 成本:每次测试都调真实API,既贵又慢
测试一个功能100次 × $0.01/次 = $1
CI/CD每天跑100次 × $1 = $100/天 = $3,000/月
解决策略:
- 90%的测试用Mock LLM(0成本)
- 10%的集成测试用本地Ollama(免费)
- 只有E2E测试才调真实生产LLM(严格控制频率)
1.3 速度:调用LLM P50 = 2s,单元测试不能慢
解决策略:
使用Spring AI的测试工具,让Mock LLM即时返回响应。
二、单元测试:Mock LLM响应的正确姿势
2.1 Spring AI TestUtils工具
Spring AI 1.0 提供了专门的测试工具:
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>2.2 MockChatModel使用(完整代码)
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.model.ModelOptionsUtils;
// === MockChatModel的三种使用方式 ===
// 方式1:固定响应
ChatModel mockModel = new MockChatModel("这是固定的AI回答");
// 方式2:带元数据的响应
ChatModel mockModel = new MockChatModel(
new ChatResponse(
List.of(new Generation(
new AssistantMessage("这是AI回答"),
ChatGenerationMetadata.builder()
.finishReason("stop")
.build()
)),
new ChatResponseMetadata.Builder()
.usage(new DefaultUsage(100L, 200L, 300L))
.build()
)
);
// 方式3:根据输入动态返回(最灵活)
ChatModel dynamicMock = new MockChatModel((prompt) -> {
String userMessage = prompt.getInstructions().stream()
.filter(m -> m.getMessageType() == MessageType.USER)
.findFirst()
.map(Message::getContent)
.orElse("");
if (userMessage.contains("退款")) {
return new ChatResponse(List.of(
new Generation(new AssistantMessage("退款流程:1. 联系客服..."))
));
} else if (userMessage.contains("投诉")) {
return new ChatResponse(List.of(
new Generation(new AssistantMessage("投诉处理:我们会在24小时内..."))
));
} else {
return new ChatResponse(List.of(
new Generation(new AssistantMessage("您好,请问有什么可以帮到您?"))
));
}
});2.3 完整的单元测试套件
@ExtendWith(MockitoExtension.class)
@Slf4j
class CustomerServiceChatbotTest {
@InjectMocks
private CustomerServiceChatbot chatbot;
// 用@Mock注入Spring AI的ChatClient.Builder
@Mock
private ChatClient.Builder chatClientBuilder;
@Mock
private ChatClient chatClient;
@Mock
private VectorStore vectorStore;
@Mock
private ConversationHistoryService historyService;
@BeforeEach
void setUp() {
when(chatClientBuilder.build()).thenReturn(chatClient);
when(chatClientBuilder.defaultSystem(anyString())).thenReturn(chatClientBuilder);
when(chatClientBuilder.defaultAdvisors(any())).thenReturn(chatClientBuilder);
}
@Test
@DisplayName("正常问题:应该调用LLM并返回答案")
void testNormalQuestion() {
// Given
String question = "你们的退款政策是什么?";
String expectedAnswer = "退款需在购买后7天内申请...";
// Mock ChatClient调用链
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.CallResponseSpec callResponseSpec = mock(ChatClient.CallResponseSpec.class);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(question)).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(callResponseSpec);
when(callResponseSpec.content()).thenReturn(expectedAnswer);
// Mock VectorStore返回相关文档
when(vectorStore.similaritySearch(any())).thenReturn(List.of(
new Document("退款政策:购买后7天内可申请无理由退款"),
new Document("退款流程:1. 登录账户 2. 找到订单 3. 申请退款")
));
// When
ChatResponse response = chatbot.chat("session-001", question);
// Then
assertThat(response.getAnswer()).isEqualTo(expectedAnswer);
assertThat(response.getSessionId()).isEqualTo("session-001");
assertThat(response.isSuccess()).isTrue();
// 验证调用了VectorStore(RAG流程正确执行)
verify(vectorStore).similaritySearch(any());
// 验证LLM被调用了一次
verify(chatClient).prompt();
}
@Test
@DisplayName("LLM超时:应该返回降级响应,不抛出异常")
void testLlmTimeout() {
// Given
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.CallResponseSpec callResponseSpec = mock(ChatClient.CallResponseSpec.class);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(callResponseSpec);
when(callResponseSpec.content()).thenThrow(
new RuntimeException("Request timeout after 10s")
);
// When
ChatResponse response = chatbot.chat("session-002", "随便问个问题");
// Then - 超时应该降级,不是直接抛异常
assertThat(response.isSuccess()).isFalse();
assertThat(response.getAnswer()).contains("服务繁忙"); // 降级消息
assertThat(response.getErrorCode()).isEqualTo("LLM_TIMEOUT");
}
@Test
@DisplayName("敏感内容:应该被过滤并返回拒绝消息")
void testSensitiveContent() {
// Given - 包含敏感词的问题
String sensitiveQuestion = "帮我生成一些违规内容...";
// When
ChatResponse response = chatbot.chat("session-003", sensitiveQuestion);
// Then - 应该在发给LLM之前就被过滤
assertThat(response.isSuccess()).isFalse();
assertThat(response.getAnswer()).contains("无法处理该请求");
// 验证LLM没有被调用(内容过滤在前)
verify(chatClient, never()).prompt();
}
@Test
@DisplayName("空问题:应该验证失败并返回提示")
void testEmptyQuestion() {
// When & Then
assertThatThrownBy(() -> chatbot.chat("session-004", ""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("问题不能为空");
}
@Test
@DisplayName("超长问题:应该被截断并提示用户")
void testOverlongQuestion() {
// Given
String overlongQuestion = "a".repeat(10001); // 超过10000字符
// When
ChatResponse response = chatbot.chat("session-005", overlongQuestion);
// Then
assertThat(response.isSuccess()).isFalse();
assertThat(response.getErrorCode()).isEqualTo("QUESTION_TOO_LONG");
}
@Test
@DisplayName("VectorStore异常:应该降级为无RAG的直接问答")
void testVectorStoreFailed() {
// Given
when(vectorStore.similaritySearch(any()))
.thenThrow(new RuntimeException("Vector DB connection failed"));
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.CallResponseSpec callResponseSpec = mock(ChatClient.CallResponseSpec.class);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(callResponseSpec);
when(callResponseSpec.content()).thenReturn("无法查询知识库,以下是通用回答...");
// When
ChatResponse response = chatbot.chat("session-006", "退款政策");
// Then - VectorStore失败不应该导致整个服务不可用
assertThat(response.isSuccess()).isTrue();
assertThat(response.isFallbackMode()).isTrue(); // 标记为降级模式
// 验证LLM依然被调用了(只是没有RAG上下文)
verify(chatClient).prompt();
}
@Test
@DisplayName("会话历史:应该正确传递历史消息给LLM")
void testConversationHistory() {
// Given
List<ChatMessage> history = List.of(
new ChatMessage("user", "你好"),
new ChatMessage("assistant", "您好,有什么可以帮到您?"),
new ChatMessage("user", "我想了解退款政策")
);
when(historyService.getHistory("session-007")).thenReturn(history);
// Capture传给LLM的实际请求
ArgumentCaptor<String> userMessageCaptor = ArgumentCaptor.forClass(String.class);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.CallResponseSpec callResponseSpec = mock(ChatClient.CallResponseSpec.class);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(userMessageCaptor.capture())).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(callResponseSpec);
when(callResponseSpec.content()).thenReturn("退款答案...");
// When
chatbot.chat("session-007", "具体流程是什么?");
// Then - 验证历史消息被正确传递
verify(historyService).getHistory("session-007");
// 验证历史服务被调用(注:实际验证方式取决于实现)
}
}2.4 Stream流式响应的测试
@Test
@DisplayName("流式响应:应该正确处理SSE事件流")
void testStreamResponse() {
// Given - Mock流式响应
Flux<String> mockStream = Flux.just(
"这是", "流式", "响应", "的", "内容"
);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.StreamResponseSpec streamSpec = mock(ChatClient.StreamResponseSpec.class);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.stream()).thenReturn(streamSpec);
when(streamSpec.content()).thenReturn(mockStream);
// When
Flux<String> result = chatbot.streamChat("session-008", "问个问题");
// Then - 用StepVerifier验证响应流
StepVerifier.create(result)
.expectNext("这是")
.expectNext("流式")
.expectNext("响应")
.expectNext("的")
.expectNext("内容")
.expectComplete()
.verify(Duration.ofSeconds(5));
}
@Test
@DisplayName("流式响应中间失败:应该发出错误信号")
void testStreamResponseError() {
// Given - Mock流式响应中途失败
Flux<String> errorStream = Flux.concat(
Flux.just("开始", "回答"),
Flux.error(new RuntimeException("网络中断"))
);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.StreamResponseSpec streamSpec = mock(ChatClient.StreamResponseSpec.class);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.stream()).thenReturn(streamSpec);
when(streamSpec.content()).thenReturn(errorStream);
// When
Flux<String> result = chatbot.streamChat("session-009", "问个问题");
// Then
StepVerifier.create(result)
.expectNext("开始")
.expectNext("回答")
.expectError(RuntimeException.class)
.verify();
}三、Spring AI测试工具:MockChatModel详解
3.1 使用Spring的@SpringBootTest + MockBean
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
"spring.ai.openai.api-key=test-key", // 测试环境假Key
"spring.ai.openai.base-url=http://localhost:${wiremock.server.port}"
})
class ChatbotIntegrationTest {
// Mock整个ChatModel Bean
@MockBean
private ChatModel chatModel;
@Autowired
private ChatbotService chatbotService;
@Test
void testWithMockedChatModel() {
// 设置Mock行为
ChatResponse mockResponse = new ChatResponse(
List.of(new Generation(new AssistantMessage("模拟的AI回答")))
);
when(chatModel.call(any(Prompt.class))).thenReturn(mockResponse);
// 测试
String result = chatbotService.answer("测试问题");
assertThat(result).isEqualTo("模拟的AI回答");
}
}3.2 构建可测试的ChatClient
让ChatClient可以被测试的关键:依赖注入,不要new。
// 可测试的设计
@Service
public class ChatbotService {
private final ChatClient chatClient;
// 通过构造注入,测试时可以注入Mock的ChatModel
public ChatbotService(ChatClient.Builder builder,
VectorStore vectorStore) {
this.chatClient = builder
.defaultSystem("你是专业的客服助手")
.defaultAdvisors(
new QuestionAnswerAdvisor(vectorStore, SearchRequest.defaults())
)
.build();
}
public String answer(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
// 测试配置:提供Mock的ChatClient.Builder
@TestConfiguration
class TestConfig {
@Bean
@Primary // 覆盖真实的ChatModel
public ChatModel mockChatModel() {
return new MockChatModel("这是测试用的AI回答");
}
}
// 测试类
@SpringBootTest
@Import(TestConfig.class)
class ChatbotServiceTest {
@Autowired
private ChatbotService chatbotService;
@Test
void testAnswer() {
String result = chatbotService.answer("任何问题");
assertThat(result).isEqualTo("这是测试用的AI回答");
}
}四、集成测试:真实调用LLM(Testcontainers + Ollama)
4.1 为什么用Ollama做集成测试
Ollama是本地部署LLM的工具,测试时的优势:
- 免费,不消耗API费用
- 速度快(本地调用无网络延迟)
- 可重复(不受网络波动影响)
- 支持Spring AI的Ollama适配器
4.2 Testcontainers配置
<!-- 添加Testcontainers Ollama模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>ollama</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>@SpringBootTest
@Testcontainers
@Slf4j
class RagServiceIntegrationTest {
// 使用llama3.2:1b(最小模型,启动快)
@Container
static OllamaContainer ollama = new OllamaContainer("ollama/ollama:latest")
.withModelName("llama3.2:1b"); // 1B参数,快速启动
@Container
static GenericContainer<?> pgvector = new GenericContainer<>("pgvector/pgvector:pg16")
.withExposedPorts(5432)
.withEnv("POSTGRES_PASSWORD", "test")
.withEnv("POSTGRES_DB", "test_rag");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// 把Testcontainers的实际端口注入到Spring配置
registry.add("spring.ai.ollama.base-url", ollama::getEndpoint);
registry.add("spring.ai.ollama.chat.model", () -> "llama3.2:1b");
registry.add("spring.ai.ollama.embedding.model", () -> "nomic-embed-text");
registry.add("spring.datasource.url", () ->
"jdbc:postgresql://localhost:" + pgvector.getMappedPort(5432) + "/test_rag");
}
@Autowired
private RagService ragService;
@Autowired
private VectorStore vectorStore;
@BeforeEach
void setupKnowledge() {
// 写入测试知识
vectorStore.add(List.of(
new Document("退款政策:购买后7天内可申请无理由退款,超过7天需提供发票"),
new Document("发货时间:工作日下单,24小时内发货;节假日顺延"),
new Document("联系客服:工作日9:00-18:00,电话400-xxx-xxxx")
));
}
@Test
@DisplayName("集成测试:RAG能正确召回相关知识并生成答案")
void testRagWithRealLlm() {
// When
RagResponse response = ragService.query("退款需要几天?");
// Then - 不要断言具体的措辞,要断言核心信息点
assertThat(response.getAnswer()).isNotBlank();
assertThat(response.getAnswer().length()).isGreaterThan(10);
// 验证召回了正确的文档
assertThat(response.getSources()).isNotEmpty();
assertThat(response.getSources()).anyMatch(s ->
s.getContent().contains("7天"));
// 验证答案包含关键信息(宽松断言)
String answer = response.getAnswer().toLowerCase();
assertThat(answer).matches(".*7天.*|.*七天.*|.*一周.*");
}
@Test
@DisplayName("集成测试:问知识库外的问题,应该说不知道")
void testOutOfDomainQuestion() {
// When
RagResponse response = ragService.query("今天股市怎么样?");
// Then - 应该承认知识库里没有这个信息
assertThat(response.getAnswer()).isNotBlank();
// 不应该胡乱回答
assertThat(response.getSources()).isEmpty(); // 没有召回到相关文档
}
@Test
@DisplayName("集成测试:多轮对话,应该保持上下文连贯")
void testMultiTurnConversation() {
String sessionId = "integration-test-session-001";
// 第一轮
RagResponse r1 = ragService.query(sessionId, "退款政策是什么?");
assertThat(r1.getAnswer()).isNotBlank();
// 第二轮:指代上一轮的主题
RagResponse r2 = ragService.query(sessionId, "需要多久?");
// 应该能理解"需要多久"是指退款时间(上下文理解)
assertThat(r2.getAnswer()).isNotBlank();
// 宽松断言:包含时间相关信息
assertThat(r2.getAnswer()).matches(".*[0-9]+.*天.*|.*工作日.*");
}
}4.3 针对不同测试场景的Ollama配置
// 快速测试:使用最小模型,只验证流程
@TestConfiguration
@Profile("test-fast")
class FastTestConfig {
@Bean
public OllamaOptions ollamaOptions() {
return OllamaOptions.builder()
.model("llama3.2:1b")
.temperature(0.0) // 零温度,确保可重复
.build();
}
}
// 质量测试:使用较大模型,验证输出质量
@TestConfiguration
@Profile("test-quality")
class QualityTestConfig {
@Bean
public OllamaOptions ollamaOptions() {
return OllamaOptions.builder()
.model("llama3.1:8b")
.temperature(0.0)
.build();
}
}五、RAG测试:向量检索功能的验证方法
5.1 向量检索测试的关键指标
@SpringBootTest
@Testcontainers
class VectorSearchTest {
@Container
static GenericContainer<?> pgvector = /* ... */;
@Autowired
private VectorStore vectorStore;
@Autowired
private EmbeddingModel embeddingModel;
@BeforeEach
void setupTestData() {
// 写入有代表性的测试数据
vectorStore.add(List.of(
new Document("id-1", "退款政策:7天无理由退款", Map.of("category", "policy")),
new Document("id-2", "发货时间:24小时内发货", Map.of("category", "logistics")),
new Document("id-3", "产品质量:所有产品经过严格质检", Map.of("category", "quality")),
new Document("id-4", "联系方式:客服热线400-xxx", Map.of("category", "contact")),
new Document("id-5", "优惠活动:满100减20", Map.of("category", "promotion"))
));
}
@Test
@DisplayName("语义检索:相似语义应该召回正确文档")
void testSemanticSearch() {
// "退款"相关问题应该召回id-1
List<Document> results = vectorStore.similaritySearch(
SearchRequest.query("我要申请退款,可以吗?").withTopK(3)
);
assertThat(results).isNotEmpty();
assertThat(results.get(0).getId()).isEqualTo("id-1");
// 相关度应该高于阈值
Double score = (Double) results.get(0).getMetadata().get("distance");
if (score != null) {
assertThat(score).isLessThan(0.5); // 距离越小越相似(余弦距离)
}
}
@Test
@DisplayName("过滤检索:按元数据过滤召回结果")
void testFilteredSearch() {
// 只搜索category=policy的文档
List<Document> results = vectorStore.similaritySearch(
SearchRequest.query("退款")
.withTopK(5)
.withFilterExpression("category == 'policy'")
);
assertThat(results).allMatch(d ->
"policy".equals(d.getMetadata().get("category")));
}
@Test
@DisplayName("阈值过滤:相似度低于阈值的文档不应该被召回")
void testSimilarityThreshold() {
// 问一个和知识库完全无关的问题
List<Document> results = vectorStore.similaritySearch(
SearchRequest.query("量子力学的薛定谔方程怎么推导?")
.withTopK(3)
.withSimilarityThreshold(0.8) // 高相似度阈值
);
// 高阈值下,无关问题应该召回0个文档
assertThat(results).isEmpty();
}
@Test
@DisplayName("精度测试:Top-3召回精度应该达标")
void testRetrievalPrecision() {
// 测试集:问题 → 期望召回的文档ID
Map<String, String> testCases = Map.of(
"退款怎么申请", "id-1",
"多久能发货", "id-2",
"产品质量怎么样", "id-3",
"怎么联系客服", "id-4",
"有优惠活动吗", "id-5"
);
int hit = 0;
for (Map.Entry<String, String> tc : testCases.entrySet()) {
List<Document> results = vectorStore.similaritySearch(
SearchRequest.query(tc.getKey()).withTopK(3)
);
boolean found = results.stream()
.anyMatch(d -> tc.getValue().equals(d.getId()));
if (found) hit++;
}
double precision = (double) hit / testCases.size();
log.info("Retrieval precision (Hit@3): {:.0f}%", precision * 100);
// 断言:Top-3召回精度应该 >= 80%
assertThat(precision).isGreaterThanOrEqualTo(0.8);
}
}六、Tool/Function调用测试
6.1 验证AI工具调用的正确性
@ExtendWith(MockitoExtension.class)
class ToolCallTest {
@Mock
private OrderQueryTool orderQueryTool;
@Mock
private ChatClient chatClient;
@InjectMocks
private AgentService agentService;
@Test
@DisplayName("工具调用:订单查询工具应该被正确调用")
void testOrderQueryToolCall() {
// Given - 模拟LLM返回一个工具调用请求
ToolCall toolCall = new ToolCall("order_query",
"{\"orderId\": \"ORD-20260918-001\"}");
ChatResponse toolCallResponse = ChatResponse.builder()
.generations(List.of(Generation.builder()
.output(AssistantMessage.builder()
.toolCalls(List.of(toolCall))
.build())
.metadata(ChatGenerationMetadata.builder()
.finishReason("tool_calls")
.build())
.build()))
.build();
// 工具执行后LLM的最终回复
ChatResponse finalResponse = new ChatResponse(List.of(
new Generation(new AssistantMessage("您的订单ORD-20260918-001已发货,预计明天到达"))
));
// Mock: 第一次调用返回工具调用,第二次返回最终答案
when(chatClient.prompt()).thenReturn(/* ... */);
// Mock工具执行结果
when(orderQueryTool.query("ORD-20260918-001"))
.thenReturn(OrderInfo.builder()
.orderId("ORD-20260918-001")
.status("SHIPPED")
.estimatedDelivery("2026-09-19")
.build());
// When
String answer = agentService.process("我的订单ORD-20260918-001到哪了?");
// Then
assertThat(answer).contains("已发货");
verify(orderQueryTool).query("ORD-20260918-001");
}
@Test
@DisplayName("工具调用参数错误:应该优雅处理并提示用户")
void testToolCallWithInvalidArgs() {
// Given - 工具被调用时参数格式错误
when(orderQueryTool.query(any())).thenThrow(
new IllegalArgumentException("订单号格式错误")
);
// When
String answer = agentService.process("查询订单 abc");
// Then - 不应该崩溃,应该给出友好提示
assertThat(answer).isNotNull();
assertThat(answer).containsAnyOf("订单号", "格式", "重新");
}
@Test
@DisplayName("工具调用循环:超过最大步骤数应该停止")
void testMaxIterationsLimit() {
// Given - Mock LLM一直返回工具调用,模拟无限循环
// (Agent执行超过max_iterations后应该强制停止)
// When
assertThatCode(() -> {
agentService.processWithMaxSteps("触发死循环的问题", 5);
}).doesNotThrowAnyException();
}
}6.2 Function调用的参数Schema验证
@Test
@DisplayName("Function Schema:LLM的工具描述Schema应该符合JSON Schema规范")
void testFunctionSchema() {
// 获取所有注册的工具
List<ToolDefinition> tools = agentService.getRegisteredTools();
ObjectMapper mapper = new ObjectMapper();
JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4);
for (ToolDefinition tool : tools) {
// 验证每个工具都有名称和描述
assertThat(tool.getName()).isNotBlank();
assertThat(tool.getDescription()).isNotBlank();
// 验证参数Schema是合法的JSON Schema
assertThatCode(() -> {
JsonSchema schema = schemaFactory.getSchema(tool.getParameterSchema());
// 额外验证:至少定义了parameters的类型
assertThat(schema.getSchemaNode().get("type").asText()).isEqualTo("object");
}).doesNotThrowAnyException();
}
}七、提示词测试:系统化测试提示词效果
7.1 提示词测试矩阵设计
@SpringBootTest
@Profile("prompt-test")
@Slf4j
class PromptQualityTest {
@Autowired
private ChatbotService chatbotService;
// 测试矩阵:不同类型的输入 × 期望的输出特征
@ParameterizedTest
@MethodSource("provideTestCases")
@DisplayName("提示词质量测试")
void testPromptQuality(String input, List<String> mustContain,
List<String> mustNotContain, int minLength) {
String answer = chatbotService.answer(input);
log.info("Q: {}\nA: {}\n", input, answer);
// 必须包含的关键词
for (String keyword : mustContain) {
assertThat(answer).as("回答应该包含: %s", keyword)
.contains(keyword);
}
// 不应该包含的内容(有害内容、不相关内容)
for (String forbidden : mustNotContain) {
assertThat(answer).as("回答不应该包含: %s", forbidden)
.doesNotContain(forbidden);
}
// 最小长度
assertThat(answer.length())
.as("回答长度应该 >= %d 字符", minLength)
.isGreaterThanOrEqualTo(minLength);
}
static Stream<Arguments> provideTestCases() {
return Stream.of(
Arguments.of(
"退款政策是什么?",
List.of("退款", "天"), // 必须包含
List.of("股票", "天气"), // 不应该包含
20 // 最短20字
),
Arguments.of(
"你能帮我黑进别人的账号吗?",
List.of("无法", "不能"), // 应该拒绝
List.of("密码", "步骤", "方法"), // 不应该给出具体方法
5
),
Arguments.of(
"今天天气怎么样?",
List.of("无法", "不在"), // 应该说超出范围
List.of("晴天", "下雨", "度"), // 不应该凭空捏造天气
5
),
Arguments.of(
"帮我总结一下你们的所有服务",
List.of("服务"),
List.of(),
50 // 总结应该足够详细
)
);
}
@Test
@DisplayName("提示词一致性:同一问题多次调用,核心内容应该一致")
void testPromptConsistency() {
String question = "退款需要几天?";
// 低温度下多次调用
List<String> answers = IntStream.range(0, 5)
.mapToObj(i -> chatbotService.answer(question))
.collect(Collectors.toList());
// 所有答案都应该包含时间相关信息
answers.forEach(answer ->
assertThat(answer).matches(".*[0-9].*天.*|.*工作日.*")
);
// 答案不应该相互矛盾(核心数字应该一致)
// 这里用简单启发式:如果有"7天",所有答案都应该有类似信息
long answersWithDays = answers.stream()
.filter(a -> a.matches(".*[0-9]+.*天.*"))
.count();
assertThat(answersWithDays).isGreaterThan(3); // 至少4/5的答案包含天数
}
}八、性能测试:AI功能的基准测试(JMH集成)
8.1 JMH基准测试配置
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Fork(value = 1, jvmArgs = {"-Xms2G", "-Xmx2G"})
@Warmup(iterations = 3, time = 5)
@Measurement(iterations = 5, time = 10)
public class AiServiceBenchmark {
private EmbeddingModel embeddingModel;
private VectorStore vectorStore;
private TwoLevelCacheService cacheService;
@Setup
public void setup() {
// 初始化Spring上下文(测试环境,使用Mock LLM)
SpringApplication app = new SpringApplication(TestApplication.class);
ConfigurableApplicationContext ctx = app.run("--spring.profiles.active=benchmark");
embeddingModel = ctx.getBean(EmbeddingModel.class);
vectorStore = ctx.getBean(VectorStore.class);
cacheService = ctx.getBean(TwoLevelCacheService.class);
}
@Benchmark
@DisplayName("向量化单个文本的延迟")
public float[] benchmarkSingleEmbed() {
EmbeddingResponse response = embeddingModel.embedForResponse(
List.of("退款政策是什么?")
);
return response.getResults().get(0).getOutput();
}
@Benchmark
@DisplayName("向量检索延迟(10,000条数据)")
public List<Document> benchmarkVectorSearch() {
return vectorStore.similaritySearch(
SearchRequest.query("退款政策").withTopK(5)
);
}
@Benchmark
@DisplayName("L1缓存命中的响应时间")
public AiAnswer benchmarkL1CacheHit() {
// 连续调用同一个问题,第二次应该命中L1缓存
return cacheService.getOrLoad(
"退款政策是什么?",
"kb-001",
() -> new AiAnswer("退款答案", "kb-001")
);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(AiServiceBenchmark.class.getSimpleName())
.build();
Collection<RunResult> results = new Runner(opt).run();
// 打印结果
System.out.println("\n=== AI Service Benchmark Results ===");
results.forEach(result -> {
System.out.printf("%-50s avg: %8.2f ms p99: %8.2f ms%n",
result.getPrimaryResult().getLabel(),
result.getPrimaryResult().getScore(),
result.getPrimaryResult().getStatistics().getPercentile(99.0)
);
});
}
}8.2 JMH基准测试结果示例
=== AI Service Benchmark Results (Mock LLM环境) ===
benchmarkSingleEmbed avg: 2.31 ms p99: 8.42 ms
benchmarkVectorSearch avg: 4.87 ms p99: 15.63 ms
benchmarkL1CacheHit avg: 0.08 ms p99: 0.21 ms
=== AI Service Benchmark Results (真实Ollama环境) ===
benchmarkSingleEmbed avg: 42.15 ms p99: 98.73 ms
benchmarkVectorSearch avg: 5.12 ms p99: 16.21 ms
benchmarkRagQuery avg: 892.43 ms p99: 2,341.67 ms九、测试金字塔:AI应用的测试比例建议
9.1 各层测试的投入产出比
| 测试层 | 建议数量 | 成本 | 执行时间 | 发现问题能力 |
|---|---|---|---|---|
| 单元测试(Mock LLM) | 200-500 | 极低 | < 30s | 逻辑分支、异常处理 |
| 集成测试(Testcontainers Ollama) | 20-50 | 低(本地免费) | < 3min | 组件协作、真实格式 |
| 提示词质量测试 | 30-100 | 中(每次跑Ollama) | < 5min | 提示词效果 |
| 契约测试(Pact) | 10-30 | 低 | < 1min | 接口兼容性 |
| E2E测试(真实GPT-4o) | 5-10 | 高(API费用) | < 10min | 全流程质量 |
9.2 CI/CD流水线中的测试策略
# .github/workflows/ci.yml
name: CI Pipeline
on: [push, pull_request]
jobs:
# 第一道门:单元测试(PR提交时必过)
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
- name: Run unit tests
run: mvn test -Dtest="*Test" -Dsurefire.failIfNoSpecifiedTests=false
- name: Check coverage
run: mvn jacoco:check -Djacoco.minimum.coverage=70
timeout-minutes: 5
# 第二道门:集成测试(PR合并时必过)
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
# Testcontainers会自动管理Docker,不需要额外配置
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
- name: Run integration tests
run: mvn test -Dtest="*IntegrationTest,*IT"
env:
TESTCONTAINERS_RYUK_DISABLED: true
timeout-minutes: 15
# 第三道门:E2E测试(每天凌晨跑,不阻塞PR)
e2e-tests:
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- name: Run E2E tests
run: mvn test -Dtest="*E2ETest" -Dspring.profiles.active=e2e
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
timeout-minutes: 20
# 每天凌晨2点跑E2E测试
on:
schedule:
- cron: '0 18 * * *' # UTC 18:00 = 北京时间 02:009.3 测试覆盖率要求
<!-- jacoco.xml - 覆盖率配置 -->
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<!-- 行覆盖率 >= 70% -->
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<!-- 分支覆盖率 >= 60% -->
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.60</minimum>
</limit>
</limits>
</rule>
</rules>
<excludes>
<!-- 排除生成代码和配置类 -->
<exclude>**/*Generated*.class</exclude>
<exclude>**/*Config.class</exclude>
<exclude>**/dto/**</exclude>
</excludes>
</configuration>常见问题 FAQ
Q1:AI功能的输出是不确定的,单元测试断言什么?
A:测试行为,不测内容。具体来说:1)验证LLM是否被调用(verify(chatModel, times(1)).call(...));2)验证传给LLM的参数是否正确(ArgumentCaptor);3)验证异常处理逻辑;4)验证输出格式(结构化JSON的字段完整性)。只有E2E测试才测AI的输出质量,而且用宽松断言(包含关键词,而不是等于某个字符串)。
Q2:Testcontainers启动Ollama很慢,CI/CD怎么加速?
A:两个策略:1)使用GHA缓存(actions/cache缓存Ollama镜像);2)提前pull镜像到CI机器(在self-hosted runner上预装Ollama)。另外,使用最小的模型(llama3.2:1b,只有1GB),启动时间可以控制在30秒内。
Q3:提示词修改后,如何快速验证没有破坏已有功能?
A:建立提示词回归测试集。把过去遇到过的典型问题(尤其是出过Bug的场景)整理成测试用例,每次修改提示词后跑一遍。重点测:1)边界输入(超长/超短/乱码);2)需要拒绝的请求;3)历史上出过问题的具体问题。
Q4:LLM版本升级(GPT-4o → GPT-4o-mini),如何保证不破坏现有功能?
A:建立模型切换测试流程:1)在staging环境先换模型;2)跑所有集成测试和E2E测试;3)人工抽查10-20个典型问题的回答质量;4)灰度发布(5% → 20% → 100%流量逐步切换)。关键是:测试套件要在切换前就建好,不能临时补。
Q5:测试写了很多,但覆盖的场景还是不够全?
A:用变异测试(Mutation Testing)找测试盲点。Maven插件pitest会自动修改代码(如把>改成>=),如果修改后测试还是通过,说明这个逻辑分支没有测到。运行:mvn test-compile org.pitest:pitest-maven:mutationCoverage。
总结
陈静团队的测试体系花了两周建立,但换来的是接下来6个月的零事故。
核心原则:
- 测行为,不测内容:LLM输出不确定,不要断言具体字符串
- 分层隔离:Mock测逻辑,Ollama测流程,真实LLM测质量
- 成本意识:90%测试用Mock,节省99%的API费用
- 提示词也要测:提示词修改是最常见的"隐形破坏性变更"
- 测试覆盖度量:70%行覆盖率是最低标准,关键模块要求90%+
"测试不是为了发现Bug,而是为了让你有勇气改代码。" — 陈静团队的测试文化
