Spring AI应用测试实战:分层策略与Mock技术详解
1. 项目概述:为什么Spring AI的测试如此重要且棘手?
最近在社区里看到不少朋友在折腾Spring AI,从调用大模型API到构建RAG应用,玩得不亦乐乎。但当我问起“你的AI功能测试覆盖了吗?”,得到的回复往往是“测了,但感觉测了个寂寞”或者“Mock起来太麻烦了,直接调真实API吧”。这恰恰点中了Spring AI开发中的一个核心痛点:如何对充满不确定性和外部依赖的AI功能进行有效、可靠且经济的测试?
Spring AI不是一个单一的库,而是一个旨在简化AI应用开发的Spring生态项目。它抽象了不同大模型提供商(如OpenAI、Anthropic、阿里云通义等)的接口,提供了Prompt模板、向量存储、智能体等高级功能。当你基于Spring AI开发一个智能客服、一个文档总结工具或一个代码生成助手时,你面临的测试挑战是双重的:一方面,你要测试你自己的业务逻辑;另一方面,你要处理AI模型这个“黑盒”带来的不确定性——它的输出是非确定性的,调用是昂贵且可能有速率限制的,并且严重依赖网络。
因此,一个完整的Spring AI测试策略必须分层处理: 单元测试 确保你的代码逻辑(如Prompt组装、结果解析)坚如磐石; 集成测试 验证你的应用与Spring AI组件、向量数据库等是否能正确协作;而 Mock技术 则是贯穿其中的“润滑剂”和“隔离墙”,让你能在不依赖真实AI服务的情况下进行前两种测试,保证测试的稳定性、速度和成本可控。搞定了这套组合拳,你的Spring AI应用才能算得上真正具备了“可测试性”,为持续集成和交付打下坚实基础。
2. 测试策略全景:单元、集成与Mock的分层设计
面对Spring AI应用,我们不能指望用一种测试方法解决所有问题。一个清晰的分层测试策略是高效和质量保障的前提。我的经验是将其划分为三个层次,各自有明确的职责和工具选型。
2.1 单元测试:聚焦内部逻辑的“白盒”验证
单元测试的目标是验证单个类或方法的行为是否符合预期,要求快速、独立且稳定。在Spring AI上下文中,单元测试主要针对那些 不直接调用AI模型 的代码。例如:
-
Prompt模板引擎
:你定义的
PromptTemplate是否正确地用变量替换了占位符? -
输出解析器
:你自定义的
OutputParser能否将模型返回的JSON或文本字符串,准确地解析成你的领域对象(如Product、Summary)? -
对话历史管理
:你的
ChatMemory实现是否能正确地添加、截断和检索消息历史? - 业务逻辑层 :在调用AI客户端前后,你的服务层所做的数据加工、校验和转换逻辑。
这个层次的测试
不应该
涉及任何真实的HTTP调用或模型推理。它的速度极快,可以在每次代码修改后立即运行,是保证代码质量的基石。我们主要使用JUnit 5作为测试框架,AssertJ或Hamcrest来编写更易读的断言,以及Mockito来隔离被测对象的外部依赖(比如,Mock掉
ChatClient
,让它返回一个预设的响应,从而测试你的解析逻辑)。
2.2 集成测试:验证组件协作的“灰盒”测试
集成测试上升一个层次,它关注多个组件如何协同工作。对于Spring AI,这通常意味着:
-
Spring AI客户端与你的配置
:你配置的
ChatClient或EmbeddingClientBean是否能够被正确注入到你的服务中?你的application.yml中的API密钥、模型参数是否正确生效? -
与向量数据库的交互
:你的
VectorStore实现(如PgVector、Redis等)能否正常执行文档的存储、相似性搜索和删除操作? - 完整的RAG链 :从文档加载、分块、嵌入到检索、增强Prompt并生成答案的整个流程,各环节衔接是否顺畅?
- Web层 :你的Controller接收请求后,是否能正确调用AI服务并返回响应?
集成测试需要启动Spring的ApplicationContext,可能会连接真实的外部服务(如测试数据库),但对于AI模型调用,在集成测试中
依然强烈建议使用Mock或Testcontainers模拟
。使用
@SpringBootTest
注解,配合
@TestConfiguration
来提供测试专用的Bean(比如一个Mock的
ChatClient
),是常见的做法。这个层次的测试比单元测试慢,但能发现组件间集成的问题。
2.3 Mock实战:贯穿始终的“隔离”艺术
Mock不是一种独立的测试类型,而是一种支撑单元和集成测试的关键技术。在Spring AI测试中,Mock的核心价值在于:
- 消除非确定性 :让每次测试运行时,AI“模型”的返回都是确定性的,保证测试结果稳定。
- 降低成本与解除限制 :避免消耗真实的API调用额度,也不受网络波动和速率限制影响。
- 模拟边界情况 :轻松模拟模型返回空值、抛出异常、返回特定格式错误等场景,测试你代码的健壮性。
我们将重点Mock两个核心接口:
ChatClient
和
EmbeddingClient
。Spring AI的良好设计(基于接口编程)使得这一切变得非常容易。在单元测试中,我们用Mockito手动创建Mock对象;在集成测试中,我们可以利用Spring的测试工具,用Mock Bean替换掉真实的Bean。
3. 单元测试深度实战:从Prompt到解析的完整链条
让我们深入代码,看一个典型的Spring AI服务单元测试应该如何构建。假设我们有一个
ArticleSummaryService
,它负责生成文章摘要。
3.1 测试Prompt模板构建
首先,我们测试Prompt的组装逻辑。这部分完全是你可控的字符串操作。
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.prompt.PromptTemplate;
import static org.assertj.core.api.Assertions.assertThat;
class PromptTemplateTest {
@Test
void givenArticleContent_whenCreateSummaryPrompt_thenPromptContainsContentAndInstruction() {
// 1. 准备测试数据
String articleContent = "Spring AI是一个强大的框架...";
String expectedInstruction = "请用中文总结以下文章,不超过100字。";
// 2. 创建PromptTemplate (这是被测对象)
String templateString = """
你是一个专业的编辑。{{instruction}}
文章内容:
{{content}}
""";
PromptTemplate promptTemplate = new PromptTemplate(templateString);
// 3. 执行被测方法
Prompt prompt = promptTemplate.create(Map.of(
"instruction", expectedInstruction,
"content", articleContent
));
// 4. 验证结果
String renderedPrompt = prompt.getContents();
assertThat(renderedPrompt).contains(expectedInstruction);
assertThat(renderedPrompt).contains(articleContent);
// 可以更精确地断言格式
assertThat(renderedPrompt).startsWith("你是一个专业的编辑。");
}
}
实操心得 :不要只断言生成的Prompt包含某些词,而要尽可能断言其结构和关键部分。这能防止模板被意外修改导致功能失效。
3.2 测试输出解析逻辑
接下来,测试我们自定义的
OutputParser
。这是单元测试的核心,因为解析逻辑是你代码中bug的高发区。
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import static org.mockito.Mockito.mock;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
// 假设我们有一个解析摘要的Parser
class SummaryOutputParser implements OutputParser<String> {
@Override
public String parse(String text) {
// 假设模型返回可能带有多余标记,我们需要清洗
String cleaned = text.replace("摘要:", "").trim();
if (cleaned.length() > 150) {
throw new IllegalArgumentException("摘要长度超过限制");
}
return cleaned;
}
@Override
public String parse(ChatResponse response) {
return parse(response.getResult().getOutput().getContent());
}
}
class SummaryOutputParserTest {
private final SummaryOutputParser parser = new SummaryOutputParser();
@Test
void givenValidModelResponse_whenParse_thenReturnCleanSummary() {
// 模拟一个ChatResponse
AssistantMessage message = new AssistantMessage("摘要:Spring AI简化了AI集成。");
Generation generation = new Generation(message);
ChatResponse mockResponse = new ChatResponse(List.of(generation));
// 执行解析
String result = parser.parse(mockResponse);
// 验证
assertThat(result).isEqualTo("Spring AI简化了AI集成。");
}
@Test
void givenResponseWithoutPrefix_whenParse_thenStillReturnsTrimmedText() {
String rawText = "Spring AI是一个优秀的框架。";
String result = parser.parse(rawText);
assertThat(result).isEqualTo(rawText); // 因为替换操作没生效,trim生效
}
@Test
void givenTooLongSummary_whenParse_thenThrowsException() {
String longText = "这是一个非常长的摘要内容,故意超过一百五十个字符的限制,以确保我们的长度校验逻辑能够正确触发异常,从而验证代码的健壮性。";
assertThatThrownBy(() -> parser.parse(longText))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("摘要长度超过限制");
}
}
注意事项
:测试解析器时,要覆盖所有边界情况:正常情况、异常格式(如JSON解析失败)、空响应、超长内容等。
assertThatThrownBy
是测试异常行为的利器。
3.3 测试包含AI客户端Mock的业务服务
最后,我们将
ChatClient
Mock掉,测试完整的服务方法。
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class) // 启用Mockito
class ArticleSummaryServiceUnitTest {
@Mock
private ChatClient chatClient; // 将被Mock的依赖
@Mock
private SummaryOutputParser outputParser; // 另一个依赖
@InjectMocks
private ArticleSummaryService summaryService; // 被测试的服务,Mock会自动注入
@Test
void givenArticle_whenGenerateSummary_thenReturnsParsedResult() {
// 1. 准备测试数据
String article = "这是一篇关于测试的文章。";
String expectedModelOutput = "摘要:这是一篇关于测试的文章的总结。";
String expectedParsedSummary = "这是一篇关于测试的文章的总结。";
// 2. 定义Mock行为
// 模拟ChatClient.call()返回一个预设的响应
AssistantMessage mockMessage = new AssistantMessage(expectedModelOutput);
ChatResponse mockChatResponse = new ChatResponse(List.of(new Generation(mockMessage)));
when(chatClient.call(any())).thenReturn(mockChatResponse); // any()匹配任何Prompt参数
// 模拟OutputParser的行为
when(outputParser.parse(mockChatResponse)).thenReturn(expectedParsedSummary);
// 3. 执行被测方法
String actualSummary = summaryService.generateSummary(article);
// 4. 验证
assertThat(actualSummary).isEqualTo(expectedParsedSummary);
// 可选:验证chatClient确实被调用了一次
// verify(chatClient, times(1)).call(any());
}
}
提示 :在单元测试中,我们使用
@ExtendWith(MockitoExtension.class)、@Mock和@InjectMocks这套组合拳,可以优雅地完成依赖注入和Mock。any()是一个通配参数匹配器,在这里表示“接受任何类型的Prompt参数”。如果你需要验证传入的Prompt内容,可以使用ArgumentCaptor来捕获参数进行更精确的断言。
4. 集成测试构建:在Spring上下文中验证协作
集成测试需要启动Spring容器。我们的目标是:在尽可能真实的环境(如有真实的数据库连接)中,但隔离掉不稳定的外部AI服务。
4.1 使用
@MockBean
替换真实AI客户端
这是最常用、最直接的方法。Spring Boot Test提供了
@MockBean
注解,它会在ApplicationContext中用一个Mockito mock替换掉同类型的真实Bean。
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest // 启动完整的Spring应用上下文
class ArticleSummaryServiceIntegrationTest {
@Autowired
private ArticleSummaryService summaryService; // 注入真实的待测服务
@MockBean // 关键:用Mock Bean替换掉上下文中真实的ChatClient
private ChatClient chatClient;
@Test
void givenArticle_whenGenerateSummaryThroughService_thenReturnsResult() {
// 模拟AI响应
String mockResponseContent = "{\"summary\": \"模拟生成的摘要\"}";
AssistantMessage message = new AssistantMessage(mockResponseContent);
ChatResponse mockResponse = new ChatResponse(List.of(new Generation(message)));
when(chatClient.call(any())).thenReturn(mockResponse);
// 调用真实的服务方法,它会使用被Mock的chatClient
String result = summaryService.generateSummary("测试内容");
// 验证服务返回了结果(即使结果是基于Mock的)
assertThat(result).isNotNull();
// 这里可以进一步验证服务内部的其他逻辑,比如是否调用了某些Repository
}
}
实操心得
:
@MockBean
非常强大,但它会影响到整个Spring上下文。如果测试类中只有部分测试需要Mock某个Bean,可以考虑使用
@TestConfiguration
在内部静态类中定义更细粒度的Bean覆盖,避免不必要的全局影响。
4.2 测试向量存储集成
对于使用PgVector、Redis等向量存储的场景,集成测试需要处理真实或模拟的数据库。 Testcontainers 是这里的最佳拍档。
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers // 启用Testcontainers支持
@SpringBootTest
class PgVectorStoreIntegrationTest {
// 定义一个共享的PostgreSQL容器(支持向量扩展)
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("pgvector/pgvector:pg16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private VectorStore vectorStore; // 自动配置的PgVectorStore
@Test
void givenDocuments_whenAddedToStore_thenCanBeRetrievedBySimilarity() {
// 1. 准备文档
Document doc1 = new Document("Spring AI is great for integration.");
Document doc2 = new Document("Testing is crucial for software quality.");
List<Document> documents = List.of(doc1, doc2);
// 2. 执行操作:添加文档
vectorStore.add(documents);
// 3. 执行查询
List<Document> results = vectorStore.similaritySearch("integration framework");
// 4. 验证
assertThat(results).isNotEmpty();
assertThat(results.get(0).getContent()).contains("Spring AI");
// 注意:相似度搜索的结果顺序可能不稳定,断言时可以检查包含关系或相似度分数
}
}
注意 :Testcontainers会启动一个真实的Docker容器来运行数据库,这保证了测试环境与生产环境的高度一致,但代价是测试速度较慢。通常只在CI/CD流水线或需要验证数据库交互的核心集成测试中使用。确保你的开发环境安装了Docker。
5. Mock高级技巧与常见问题排查
掌握了基础Mock之后,我们来看看一些能让你事半功倍的高级技巧和那些容易踩的坑。
5.1 模拟流式响应
如果您的应用使用流式响应(例如,用于实现打字机效果),Mock会稍微复杂一点,因为需要模拟一个
Flux
流。
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import java.time.Duration;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@Test
void givenArticle_whenGenerateSummaryStream_thenReceivesStreamingResponse() {
// 模拟一个包含多个块的流式响应
ChatResponse chunk1 = new ChatResponse(List.of(new Generation(new AssistantMessage("摘要"))));
ChatResponse chunk2 = new ChatResponse(List.of(new Generation(new AssistantMessage(":这是一"))));
ChatResponse chunk3 = new ChatResponse(List.of(new Generation(new AssistantMessage("个总结。"))));
// 创建包含延迟的Flux,模拟真实流
Flux<ChatResponse> mockFlux = Flux.just(chunk1, chunk2, chunk3)
.delayElements(Duration.ofMillis(50));
when(chatClient.stream(any())).thenReturn(mockFlux); // 假设你的client有一个stream方法
// 然后你的测试代码可以订阅这个flux并验证收到的内容块
// 例如,使用StepVerifier(来自reactor-test)
}
5.2 使用
ArgumentCaptor
进行精确断言
有时,你不仅需要Mock返回一个值,还需要验证传递给Mock方法的参数是否正确。这在测试Prompt的组装时非常有用。
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.prompt.Prompt;
import static org.mockito.Mockito.verify;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void givenArticle_whenGenerateSummary_thenPromptContainsSpecificInstruction() {
// ... 模拟chatClient.call()返回 ...
// 执行测试
summaryService.generateSummary("文章内容");
// 捕获传递给chatClient.call()方法的实际Prompt参数
ArgumentCaptor<Prompt> promptCaptor = ArgumentCaptor.forClass(Prompt.class);
verify(chatClient).call(promptCaptor.capture()); // 验证call被调用,并捕获参数
Prompt actualPrompt = promptCaptor.getValue();
String promptText = actualPrompt.getContents();
// 精确断言Prompt内容
assertThat(promptText).contains("你是一个专业的编辑");
assertThat(promptText).contains("文章内容");
assertThat(promptText).doesNotContain("无关指令");
}
5.3 常见问题排查实录
在实际操作中,我遇到了不少问题,这里分享几个典型的:
问题1:
@MockBean
不生效,仍然调用了真实的AI接口。
-
排查
:首先检查导入的包是否正确。确保是
org.springframework.boot.test.mock.mockito.MockBean,而不是Mockito的@Mock。其次,检查Bean的类型是否匹配。Spring上下文可能存在多个同类型的Bean,或者你的Bean是通过@Bean方法以特定名称定义的,类型匹配可能出错。 -
解决
:使用
@Qualifier或在@MockBean中指定Bean的name属性。更彻底的方法是,在测试配置中,使用@TestConfiguration显式地提供一个Mock的Bean定义,覆盖掉主配置。
问题2:集成测试启动速度极慢。
-
排查
:
@SpringBootTest默认会加载整个应用上下文。如果你的应用很大,启动一次可能需要几十秒。 -
解决
:
-
使用
@SpringBootTest(webEnvironment = WebEnvironment.NONE):如果你的测试不涉及Web层(如Controller),禁用Web环境可以加快启动。 -
使用
@DataJpaTest,@WebMvcTest等切片测试注解 :Spring Boot提供了针对特定层的测试切片,只加载相关的配置,速度飞快。但对于涉及多个组件的Spring AI服务测试,可能不适用。 -
优化Bean的懒加载
:在生产配置中合理使用
@Lazy注解。 -
缓存ApplicationContext
:使用
@DirtiesContext注解控制上下文何时刷新,避免每个测试方法都重启。
-
使用
问题3:Mock的
ChatResponse
构造复杂,代码冗长。
-
排查
:
ChatResponse及其内部的Generation、AssistantMessage等对象构造确实需要多行代码。 -
解决
:创建测试工具类(Test Fixtures)。例如,建立一个
TestChatResponseUtil类,提供像createSimpleResponse(String content)这样的静态工厂方法,让测试代码更简洁。
// 测试工具类示例
public class TestAiUtils {
public static ChatResponse createSimpleChatResponse(String content) {
return new ChatResponse(List.of(new Generation(new AssistantMessage(content))));
}
// 可以添加更多创建复杂响应的方法
}
// 在测试中使用
when(chatClient.call(any())).thenReturn(TestAiUtils.createSimpleChatResponse("Mocked response"));
问题4:如何模拟模型调用异常(如网络超时、API限流)?
-
解决
:使用Mockito的
thenThrow方法。
import org.springframework.ai.chat.model.ChatModelAccessException;
import static org.mockito.Mockito.when;
@Test
void whenAIServiceTimesOut_thenThrowsCustomException() {
// 模拟chatClient.call()抛出Spring AI定义的操作异常
when(chatClient.call(any()))
.thenThrow(new ChatModelAccessException("Simulated API timeout or rate limit"));
// 断言你的服务能正确处理这种异常,例如转换为业务异常或进行重试
assertThatThrownBy(() -> summaryService.generateSummary("test"))
.isInstanceOf(YourBusinessException.class)
.hasMessageContaining("AI服务调用失败");
}
问题5:测试覆盖率报告中,AI客户端的调用行被标记为未覆盖。
- 排查 :这是正常的,因为你用Mock替换了真实实现。你的单元/集成测试目标是覆盖 你的业务逻辑 ,而不是Spring AI库本身的代码。
-
解决
:正确理解测试覆盖率的含义。关注你自定义的Prompt模板、输出解析器、错误处理、业务规则等代码的覆盖率。AI客户端的调用行(如
chatClient.call(prompt))本身只是一个接口调用,其内部逻辑由Spring AI和模型提供商负责,不在你的单元测试范围内。确保调用这行代码的 前后逻辑 (如参数构建、结果处理)被覆盖即可。
更多推荐


所有评论(0)