第1867篇:Spring AI测试支持——TestSlice与MockMvc在AI控制器测试中的应用
第1867篇:Spring AI测试支持——TestSlice与MockMvc在AI控制器测试中的应用
说一个让很多 AI 项目质量堪忧的现象:代码库里测试覆盖率很低,特别是 AI 相关的代码,经常是"手动测了能跑就行"。
原因我理解——AI 接口测试有几个麻烦:
- 每次测试都要真实调用 AI 接口,慢、贵,还依赖网络
- AI 返回结果有随机性,断言怎么写?
- 流式响应怎么测?
- RAG 的检索结果测试链路更复杂
但这些麻烦不是测试不可行的理由,而是没找到正确的测试方式。Spring AI 本身提供了测试支持,配合 Spring Boot Test 的 @WebMvcTest、MockMvc 和 Mock 对象,完全可以把 AI 接口的测试写得又快又稳定。
今天把这套测试体系完整讲一遍,代码都是可以直接用的。
一、测试分层策略
AI 项目的测试也要分层,不是所有测试都要端到端调用真实模型:
原则:能 Mock 的绝不真实调用,真实调用只做冒烟测试。
二、Spring AI 提供的测试支持
Spring AI 有个 spring-ai-test 模块,专门为测试提供工具类:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<scope>test</scope>
</dependency>核心工具:
MockChatModel:可以预置返回结果的 ChatModel Mock 实现MockEmbeddingModel:可以预置向量结果的 EmbeddingModel Mock 实现
三、用 @WebMvcTest 做 Controller 测试
@WebMvcTest 只加载 Web 层的 bean(Controller、Filter、WebMvcConfigurer),不加载 Service、Repository 等,速度很快:
@WebMvcTest(ChatController.class)
@Import(SecurityConfig.class) // 如果有安全配置需要引入
class ChatControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ChatService chatService; // 用 @MockBean 替代真实 Service
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(username = "testuser", roles = "USER")
void testChatEndpoint_success() throws Exception {
// 准备
String expectedResponse = "这是一个测试回答";
given(chatService.chat(eq("testuser"), anyString(), anyString()))
.willReturn(expectedResponse);
ChatRequestDTO request = new ChatRequestDTO();
request.setMessage("你好");
request.setConversationId("conv-123");
// 执行 + 断言
mockMvc.perform(post("/api/v1/chat")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.content").value(expectedResponse))
.andDo(print());
}
@Test
@WithMockUser(username = "testuser", roles = "USER")
void testChatEndpoint_emptyMessage_returnsBadRequest() throws Exception {
ChatRequestDTO request = new ChatRequestDTO();
request.setMessage(""); // 空消息,应该触发参数校验
request.setConversationId("conv-123");
mockMvc.perform(post("/api/v1/chat")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void testChatEndpoint_withoutAuth_returnsUnauthorized() throws Exception {
ChatRequestDTO request = new ChatRequestDTO();
request.setMessage("你好");
mockMvc.perform(post("/api/v1/chat")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", roles = "USER")
void testChatEndpoint_serviceThrowsException_returns503() throws Exception {
given(chatService.chat(any(), any(), any()))
.willThrow(new AiException("模型调用超时"));
ChatRequestDTO request = new ChatRequestDTO();
request.setMessage("测试消息");
request.setConversationId("conv-123");
mockMvc.perform(post("/api/v1/chat")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value(503));
}
}四、流式接口的测试
SSE 流式接口的测试稍微麻烦一些,需要处理 Flux 的结果:
@WebMvcTest(ChatController.class)
class StreamChatControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ChatService chatService;
@Test
@WithMockUser(username = "testuser", roles = "USER")
void testStreamChatEndpoint_success() throws Exception {
// 准备流式数据
Flux<String> mockStream = Flux.just("这", "是", "一个", "测试", "回答");
given(chatService.streamChat(eq("testuser"), anyString(), anyString()))
.willReturn(mockStream);
ChatRequestDTO request = new ChatRequestDTO();
request.setMessage("你好");
request.setConversationId("conv-123");
MvcResult result = mockMvc.perform(post("/api/v1/chat/stream")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(request))
.accept(MediaType.TEXT_EVENT_STREAM))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type",
containsString("text/event-stream")))
.andReturn();
// 检查 SSE 响应内容
String responseContent = result.getResponse().getContentAsString();
assertThat(responseContent).contains("data:这");
assertThat(responseContent).contains("data:回答");
}
@Test
@WithMockUser(username = "testuser", roles = "USER")
void testStreamChatEndpoint_errorInStream_returnsErrorEvent() throws Exception {
// 模拟流中途出错
Flux<String> errorStream = Flux.concat(
Flux.just("开始", "回答"),
Flux.error(new RuntimeException("模型超时"))
);
given(chatService.streamChat(any(), any(), any()))
.willReturn(errorStream);
ChatRequestDTO request = new ChatRequestDTO();
request.setMessage("你好");
request.setConversationId("conv-123");
MvcResult result = mockMvc.perform(post("/api/v1/chat/stream")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(request))
.accept(MediaType.TEXT_EVENT_STREAM))
.andReturn();
String responseContent = result.getResponse().getContentAsString();
// 确保错误事件被正确发送
assertThat(responseContent).contains("event:error");
}
}五、Service 层的单元测试
Service 层是业务逻辑的核心,这里用 MockChatModel 来避免真实调用:
class ChatServiceTest {
private ChatService chatService;
private ChatClient chatClient;
@BeforeEach
void setUp() {
// 使用 Spring AI Test 提供的 MockChatModel
MockChatModel mockChatModel = MockChatModel.builder()
.withDefaultResponse("这是默认的模拟回答")
.build();
// 用 MockChatModel 构建 ChatClient
chatClient = ChatClient.builder(mockChatModel).build();
chatService = new ChatService(chatClient);
}
@Test
void testChat_normalMessage_returnsResponse() {
String result = chatService.chat("user1", "conv-1", "你好");
assertThat(result).isNotBlank();
assertThat(result).isEqualTo("这是默认的模拟回答");
}
@Test
void testChat_withDifferentResponses() {
// MockChatModel 支持配置多个按序返回的响应
MockChatModel mockModel = MockChatModel.builder()
.withResponses(
"第一次回答",
"第二次回答",
"第三次回答"
)
.build();
ChatClient client = ChatClient.builder(mockModel).build();
ChatService service = new ChatService(client);
assertThat(service.chat("user1", "conv-1", "问题1")).isEqualTo("第一次回答");
assertThat(service.chat("user1", "conv-1", "问题2")).isEqualTo("第二次回答");
assertThat(service.chat("user1", "conv-1", "问题3")).isEqualTo("第三次回答");
}
@Test
void testChat_modelThrowsException_throwsAiException() {
MockChatModel errorModel = MockChatModel.builder()
.withException(new RuntimeException("API 调用失败"))
.build();
ChatClient client = ChatClient.builder(errorModel).build();
ChatService service = new ChatService(client);
assertThatThrownBy(() -> service.chat("user1", "conv-1", "你好"))
.isInstanceOf(AiException.class);
}
}六、Advisor 的单元测试
Advisor 是业务逻辑的重要部分,也需要独立测试:
class ContentSafetyAdvisorTest {
private ContentSafetyAdvisor advisor;
private ContentSafetyChecker mockChecker;
private CallAroundAdvisorChain mockChain;
@BeforeEach
void setUp() {
mockChecker = mock(ContentSafetyChecker.class);
mockChain = mock(CallAroundAdvisorChain.class);
advisor = new ContentSafetyAdvisor(mockChecker);
}
@Test
void testAroundCall_safeContent_callsChain() {
// 准备:内容安全
given(mockChecker.check(anyString()))
.willReturn(ContentCheckResult.safe());
AdvisedRequest request = buildTestRequest("你好,请介绍一下Spring框架");
AdvisedResponse expectedResponse = buildTestResponse("Spring是一个Java框架");
given(mockChain.nextAroundCall(any())).willReturn(expectedResponse);
// 执行
AdvisedResponse actual = advisor.aroundCall(request, mockChain);
// 断言
assertThat(actual).isEqualTo(expectedResponse);
verify(mockChain, times(1)).nextAroundCall(any());
}
@Test
void testAroundCall_unsafeContent_returnsRefusal() {
// 准备:内容不安全
given(mockChecker.check(anyString()))
.willReturn(ContentCheckResult.unsafe(ViolationCategory.ADULT_CONTENT));
AdvisedRequest request = buildTestRequest("一些不当内容");
// 执行
AdvisedResponse actual = advisor.aroundCall(request, mockChain);
// 断言:不应该调用 chain,直接返回拒绝响应
verify(mockChain, never()).nextAroundCall(any());
String responseContent = actual.response().getResult().getOutput().getContent();
assertThat(responseContent).contains("健康文明");
}
@Test
void testGetOrder_returnsHighestPriority() {
assertThat(advisor.getOrder()).isEqualTo(0);
}
@Test
void testGetName_returnsExpectedName() {
assertThat(advisor.getName()).isEqualTo("ContentSafetyAdvisor");
}
private AdvisedRequest buildTestRequest(String userText) {
return AdvisedRequest.builder()
.withUserText(userText)
.build();
}
private AdvisedResponse buildTestResponse(String content) {
AssistantMessage message = new AssistantMessage(content);
ChatResponse chatResponse = new ChatResponse(List.of(new Generation(message)));
return new AdvisedResponse(chatResponse, Map.of());
}
}七、RAG 服务的测试
RAG 服务涉及向量检索和模型调用,两部分都需要 Mock:
class RagServiceTest {
private RagService ragService;
private VectorStore mockVectorStore;
private ChatClient chatClient;
@BeforeEach
void setUp() {
mockVectorStore = mock(VectorStore.class);
MockChatModel mockChatModel = MockChatModel.builder()
.withDefaultResponse("根据提供的资料,答案是:42")
.build();
chatClient = ChatClient.builder(mockChatModel).build();
ragService = new RagService(chatClient, mockVectorStore);
}
@Test
void testRagQuery_withRelevantDocs_includesContextInPrompt() {
// 准备:模拟检索到的相关文档
List<Document> mockDocs = List.of(
new Document("文档1的内容:Spring AI 是一个框架"),
new Document("文档2的内容:它支持多种 AI 模型")
);
given(mockVectorStore.similaritySearch(any())).willReturn(mockDocs);
// 执行
String result = ragService.query("什么是Spring AI?");
// 断言
assertThat(result).isNotBlank();
// 验证确实调用了向量检索
verify(mockVectorStore, times(1)).similaritySearch(any());
}
@Test
void testRagQuery_noRelevantDocs_handlesGracefully() {
given(mockVectorStore.similaritySearch(any())).willReturn(List.of());
String result = ragService.query("一个没有相关文档的问题");
// 即使没有相关文档,也应该正常返回(模型会说不知道)
assertThat(result).isNotNull();
}
@Test
void testRagQuery_vectorStoreThrowsException_throwsRagException() {
given(mockVectorStore.similaritySearch(any()))
.willThrow(new RuntimeException("向量存储连接失败"));
assertThatThrownBy(() -> ragService.query("测试问题"))
.isInstanceOf(RagException.class)
.hasMessageContaining("检索失败");
}
}八、集成测试:用 @SpringBootTest 测试完整流程
集成测试验证组件之间的协作,这里同样用 Mock 替换真实的 AI 调用:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class ChatIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
// 在集成测试里替换真实的 ChatModel
@MockBean
private ChatModel chatModel;
@BeforeEach
void setUp() {
// 配置 Mock ChatModel 的行为
ChatResponse mockResponse = buildMockResponse("集成测试回答");
given(chatModel.call(any(Prompt.class))).willReturn(mockResponse);
}
@Test
@WithMockUser(username = "integtest", roles = "USER")
void testFullChatFlow_success() throws Exception {
ChatRequestDTO request = new ChatRequestDTO();
request.setMessage("集成测试消息");
request.setConversationId("integ-conv-1");
mockMvc.perform(post("/api/v1/chat")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").exists());
}
@Test
@WithMockUser(username = "integtest", roles = "USER")
void testMultipleConversationTurns_maintainsContext() throws Exception {
// 第一轮对话
ChatRequestDTO request1 = new ChatRequestDTO();
request1.setMessage("我叫小明");
request1.setConversationId("integ-conv-2");
mockMvc.perform(post("/api/v1/chat")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request1)))
.andExpect(status().isOk());
// 第二轮对话
ChatRequestDTO request2 = new ChatRequestDTO();
request2.setMessage("我叫什么名字?");
request2.setConversationId("integ-conv-2");
mockMvc.perform(post("/api/v1/chat")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request2)))
.andExpect(status().isOk());
// 验证 chatModel 被调用了两次
verify(chatModel, times(2)).call(any(Prompt.class));
}
private ChatResponse buildMockResponse(String content) {
AssistantMessage message = new AssistantMessage(content);
Generation generation = new Generation(message);
return new ChatResponse(List.of(generation));
}
}九、测试配置文件
测试环境需要专门的配置文件,禁用掉生产环境的一些功能:
# src/test/resources/application-test.yml
spring:
ai:
openai:
api-key: "test-key-not-real" # 测试不会真正调用,给个占位值
# 禁用部分功能
management:
endpoints:
web:
exposure:
include: health
# 测试环境日志级别
logging:
level:
com.example.aiapp: DEBUG
org.springframework.ai: DEBUG
org.springframework.security: DEBUG在测试类上激活测试 profile:
@SpringBootTest
@ActiveProfiles("test")
class ChatIntegrationTest { ... }十、测试覆盖率关注点
AI 项目测试的重点不在于追求高覆盖率数字,而在于覆盖这几类关键场景:
必须测试的场景:
- 正常请求 → 正常响应
- 请求参数校验(空消息、超长消息)
- 未认证请求 → 401
- 无权限请求 → 403
- AI 模型异常 → 503(不能直接把底层异常返回给用户)
- 限流触发 → 429
推荐测试的场景:
- 流式接口的正常和异常路径
- 多轮对话的上下文保持
- Advisor 的业务逻辑(内容安全、缓存)
- RAG 检索不到相关文档时的降级行为
说实话,AI 项目测试难写的核心原因是:很多团队在写代码时就没有为可测试性设计。如果 Service 直接依赖 ChatClient 而不是接口,如果 Advisor 里耦合了数据库查询,测试就会很难写。
先把代码写得便于测试,测试自然就好写了。
