第2335篇:Java AI测试框架设计——构建可维护的AI应用测试体系
2026/4/30大约 7 分钟
第2335篇:Java AI测试框架设计——构建可维护的AI应用测试体系
适读人群:负责AI应用质量保障的Java工程师,希望建立系统化AI测试方案的团队 | 阅读时长:约20分钟 | 核心价值:建立从单元测试到端到端评估的AI应用测试体系,解决AI测试的不确定性问题
AI应用的测试比传统应用难,难在"没有标准答案"。
传统单元测试:assertEquals("hello world", service.greet())——确定性的。
AI单元测试:assertEquals(???, chatService.answer("Java中的线程是什么?"))——AI每次回答可能不一样,你没法写一个确定性的assert。
这让很多团队直接放弃了AI应用的测试,"反正是AI生成的,测了也没用"。
这个思路是错的。AI应用的不确定性不在于逻辑,而在于生成内容。逻辑层面(调用链路、错误处理、工具调用)完全可以测试;内容质量层面,也有评估方法,只是不用assertEquals。
这篇文章给出一套完整的AI应用测试策略。
测试金字塔:AI应用版
越往上越贵越慢,但覆盖越全面。测试预算有限时,优先保证底层。
第一层:单元测试——Mock掉AI调用
对于Service层的业务逻辑,用Mock来隔离实际的LLM调用:
@ExtendWith(MockitoExtension.class)
class RagServiceTest {
@Mock
private ChatClient chatClient;
@Mock
private ChatClient.ChatClientRequestSpec requestSpec;
@Mock
private ChatClient.CallResponseSpec callResponseSpec;
@Mock
private VectorStore vectorStore;
@InjectMocks
private RagService ragService;
@Test
@DisplayName("正常RAG查询:有相关文档时应返回基于文档的回答")
void testQueryWithRelevantDocuments() {
// Given: 向量库返回相关文档
List<Document> mockDocs = List.of(
new Document("Java中的线程是轻量级进程,用于实现并发"),
new Document("Thread类实现了Runnable接口,可以通过继承Thread或实现Runnable来创建线程")
);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(mockDocs);
// Given: LLM返回回答
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.system(anyString())).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(callResponseSpec);
when(callResponseSpec.content()).thenReturn("Java线程是轻量级进程,实现并发执行");
// When
RagService.RagResponse response = ragService.query("Java中的线程是什么?");
// Then
assertThat(response.success()).isTrue();
assertThat(response.answer()).isNotBlank();
assertThat(response.sources()).isNotEmpty();
// 验证向量检索被调用了(行为验证)
verify(vectorStore).similaritySearch(any(SearchRequest.class));
// 验证LLM被调用了,且包含了文档内容
verify(requestSpec).system(contains("Java中的线程是轻量级进程"));
}
@Test
@DisplayName("无相关文档时:应返回无法回答,而不是胡编乱造")
void testQueryWithNoRelevantDocuments() {
// Given: 向量库返回空结果
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());
// When
RagService.RagResponse response = ragService.query("量子计算机的工作原理是什么?");
// Then: 不应该调用LLM(没有相关文档就不该胡答)
verify(chatClient, never()).prompt();
assertThat(response.success()).isFalse();
assertThat(response.answer()).isNull();
}
@Test
@DisplayName("LLM调用超时时:应该返回降级响应,不应该抛异常到调用方")
void testLlmTimeout() {
// Given: 向量库返回文档
when(vectorStore.similaritySearch(any())).thenReturn(
List.of(new Document("相关内容")));
// Given: LLM调用超时
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.system(anyString())).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(callResponseSpec);
when(callResponseSpec.content()).thenThrow(new RuntimeException("Connection timeout"));
// When & Then: 应该优雅处理,不抛出Runtime异常
assertThatNoException().isThrownBy(() -> ragService.query("测试问题"));
RagService.RagResponse response = ragService.query("测试问题");
// 降级响应应该有意义,不是null
assertThat(response).isNotNull();
}
}第二层:工具调用测试
Function Calling的工具方法是业务逻辑,必须单独测试:
@SpringBootTest
@ActiveProfiles("test")
class OrderToolsTest {
@Autowired
private OrderTools orderTools;
@MockBean
private OrderRepository orderRepository;
@Test
@DisplayName("查询存在的订单:应返回完整订单信息")
void testQueryExistingOrder() {
// Given
Order mockOrder = Order.builder()
.id("ORD-20260401-001")
.status(OrderStatus.PAID)
.amount(new BigDecimal("299.00"))
.createdAt(LocalDateTime.now().minusDays(1))
.build();
when(orderRepository.findById("ORD-20260401-001")).thenReturn(Optional.of(mockOrder));
// When
OrderTools.OrderDetail result = orderTools.queryOrder("ORD-20260401-001");
// Then
assertThat(result.id()).isEqualTo("ORD-20260401-001");
assertThat(result.status()).isEqualTo("PAID");
assertThat(result.amount()).isEqualByComparingTo("299.00");
}
@Test
@DisplayName("查询不存在的订单:应该抛出有意义的异常(让LLM能理解错误原因)")
void testQueryNonExistentOrder() {
when(orderRepository.findById(anyString())).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> orderTools.queryOrder("ORD-99999-999"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ORD-99999-999");
// 注意:工具方法抛出的异常消息会被Spring AI捕获并传递给LLM
// 所以错误消息要有意义,让LLM能告诉用户发生了什么
}
@Test
@DisplayName("取消已支付的订单:应拒绝并返回明确原因")
void testCancelPaidOrder() {
Order paidOrder = Order.builder()
.id("ORD-20260401-002")
.status(OrderStatus.PAID)
.build();
when(orderRepository.findById("ORD-20260401-002")).thenReturn(Optional.of(paidOrder));
// When
String result = orderTools.cancelOrder("ORD-20260401-002", "不想要了");
// Then: 应该返回字符串说明原因,而不是抛异常
// (工具方法返回字符串时,LLM能够理解并转告用户)
assertThat(result).contains("取消失败");
assertThat(result).contains("PAID");
}
}第三层:集成测试——用WireMock模拟LLM API
完整链路测试,不调用真实LLM API,而是用WireMock模拟:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 9999)
class ChatServiceIntegrationTest {
@Autowired
private ChatService chatService;
@Test
@DisplayName("完整的聊天链路:从请求到解析响应")
void testFullChatFlow(WireMockRuntimeInfo wireMock) {
// 模拟OpenAI的响应格式
String openAiResponse = """
{
"id": "chatcmpl-test123",
"object": "chat.completion",
"created": 1709000000,
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Java虚拟线程是Java 21引入的轻量级线程实现。"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 25,
"completion_tokens": 18,
"total_tokens": 43
}
}
""";
// 配置WireMock响应
stubFor(post(urlEqualTo("/v1/chat/completions"))
.willReturn(okJson(openAiResponse)));
// When
String result = chatService.chat("user-001", "Java虚拟线程是什么?");
// Then
assertThat(result).isNotBlank();
assertThat(result).contains("Java"); // 包含关键词
// 验证实际发出了正确格式的HTTP请求
verify(postRequestedFor(urlEqualTo("/v1/chat/completions"))
.withRequestBody(matchingJsonPath("$.messages[?(@.role == 'user')]"))
.withRequestBody(matchingJsonPath("$.model")));
}
}第四层:评估测试(Evaluation Test)
这是AI应用测试里最特殊的一层——不用assertEquals,而是用LLM或规则来评估回答质量:
@SpringBootTest
@Tag("evaluation") // 评估测试单独跑,不加入CI的快速测试集
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RagQualityEvaluationTest {
@Autowired
private RagService ragService;
@Autowired
private ChatClient evaluatorClient; // 专门用于评估的ChatClient
// 评估数据集:问题 + 期望的关键点
private static final List<EvalCase> EVAL_CASES = List.of(
new EvalCase(
"什么是Spring AI的ChatClient?",
List.of("ChatClient", "Spring AI", "聊天", "接口"),
true // 期望能回答
),
new EvalCase(
"量子纠缠的原理是什么?",
List.of(),
false // 期望无法回答(知识库里没有这个)
)
);
@ParameterizedTest
@MethodSource("evalCases")
@DisplayName("RAG回答质量评估")
void evaluateRagQuality(EvalCase evalCase) {
// When
RagService.RagResponse response = ragService.query(evalCase.question());
// Basic assertion: 响应是否符合预期
if (!evalCase.shouldAnswer()) {
assertThat(response.success()).isFalse();
return;
}
assertThat(response.success()).isTrue();
// 关键词覆盖检查(简单评估)
String answer = response.answer().toLowerCase();
long keywordsFound = evalCase.expectedKeywords().stream()
.filter(kw -> answer.contains(kw.toLowerCase()))
.count();
double coverage = (double) keywordsFound / evalCase.expectedKeywords().size();
assertThat(coverage)
.as("关键词覆盖率应大于70%%,实际:%.1f%%,问题:%s",
coverage * 100, evalCase.question())
.isGreaterThan(0.7);
// LLM评估(可选,更智能但更贵)
if (isLlmEvaluationEnabled()) {
int score = evaluateWithLlm(evalCase.question(), response.answer());
assertThat(score)
.as("LLM评分应大于等于7,实际:%d,问题:%s", score, evalCase.question())
.isGreaterThanOrEqualTo(7);
}
}
private int evaluateWithLlm(String question, String answer) {
String evaluationPrompt = String.format("""
评估以下问答对的质量,返回1-10的整数分数(只返回数字):
问题:%s
回答:%s
评分标准:
- 10: 完全准确、全面、清晰
- 7-9: 基本准确,有小缺陷
- 4-6: 部分准确,有明显缺失
- 1-3: 不准确或无关
""", question, answer);
String scoreStr = evaluatorClient.prompt()
.user(evaluationPrompt)
.call()
.content()
.trim();
try {
return Integer.parseInt(scoreStr);
} catch (NumberFormatException e) {
// 如果LLM没有按格式返回,给一个中间分
return 5;
}
}
private boolean isLlmEvaluationEnabled() {
return System.getProperty("eval.llm.enabled", "false").equals("true");
}
static Stream<EvalCase> evalCases() {
return EVAL_CASES.stream();
}
record EvalCase(String question, List<String> expectedKeywords, boolean shouldAnswer) {}
}CI/CD中的测试分层
# GitHub Actions示例
jobs:
unit-tests:
name: 单元测试(每次提交)
runs-on: ubuntu-latest
steps:
- run: ./mvnw test -Dgroups="!integration,!evaluation"
integration-tests:
name: 集成测试(PR合并时)
runs-on: ubuntu-latest
needs: unit-tests
steps:
- run: ./mvnw test -Dgroups="integration"
evaluation-tests:
name: 评估测试(每周)
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- run: ./mvnw test -Dgroups="evaluation" -Deval.llm.enabled=true
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}AI应用的测试体系建立起来后,最直接的收益是:每次改了Prompt或换了模型,你知道质量有没有下降,而不是靠感觉或靠用户反馈。
