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 EmbeddingClient Bean是否能够被正确注入到你的服务中?你的 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的核心价值在于:

  1. 消除非确定性 :让每次测试运行时,AI“模型”的返回都是确定性的,保证测试结果稳定。
  2. 降低成本与解除限制 :避免消耗真实的API调用额度,也不受网络波动和速率限制影响。
  3. 模拟边界情况 :轻松模拟模型返回空值、抛出异常、返回特定格式错误等场景,测试你代码的健壮性。

我们将重点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和模型提供商负责,不在你的单元测试范围内。确保调用这行代码的 前后逻辑 (如参数构建、结果处理)被覆盖即可。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐