Skip to content

集成测试与假模型:不烧钱也能测 AI 代码 ​

1. 本节产出 ​

一套分层的 AI 测试:单元测试用假模型跑(0 成本、秒级),集成测试用 Testcontainers 起真实数据库验证持久化,另有一小组冒烟测试打真实厂商(每天一次,有预算上限)。mvn test 全程不花钱也能跑绿。

2. 前置依赖 ​

3. 为什么 AI 项目的测试必须分三层 ​

常见的两种极端,都是错的:

极端一:所有测试都打真实模型。 后果:跑一次测试几块钱,CI 上一天几十次就是几百块;测试不稳定(模型输出随机);没有 Key 的同事跑不了;网络抖动就红灯。最终团队会关掉 CI,回归保护归零。

极端二:只测自己的代码,测模型调用全部 mock 掉。 后果:mock 说返回 A,真实模型返回 B,你的解析代码在线上崩。这类 bug 只有真跑才能发现。

正确做法:金字塔三层,每层解决不同问题。

层用什么测什么成本频次
单元测试假 ChatModel你的编排逻辑、解析逻辑、分支处理0每次提交
集成测试Testcontainers + 假模型数据库持久化、Advisor 顺序、配置装配0每次提交
冒烟测试真实厂商模型是否仍遵循 Schema、工具是否仍触发几毛钱每天一次

关键认知:AI 应用里真正需要测的是「模型之外的所有代码」。 模型本身不在你的测试范围内——它是外部依赖,就像数据库一样。你不会在单元测试里验证 MySQL 的查询引擎,同样不该在这里验证模型有多聪明。

4. 核心原理 ​

4.1 假模型怎么实现 ​

ChatModel 是一个接口,实现它就能造一个假模型:

java
public interface ChatModel {
    ChatResponse call(Prompt prompt);
    ChatOptions getDefaultOptions();
}

只需要实现 call()。给不同的输入返回预设的 ChatResponse,就能模拟各种场景——包括正常回答、工具调用请求、超长输出、异常。

4.2 用假模型能测到什么 ​

场景怎么模拟测什么
正常回答返回固定文本编排逻辑、结果处理
触发工具调用返回带 ToolCall 的响应工具是否被正确执行、结果是否回填
结构化输出返回预设 JSON(含代码块噪声)Converter 清洗与反序列化
上下文超限抛特定异常你的压缩逻辑是否触发
限流抛 429 异常重试与降级是否正确
慢响应sleep 一段时间超时是否生效

最后三项是最有价值的——这些路径在线上会走,但靠真实模型很难稳定复现。假模型让「异常路径」变成可测试的常规路径。

4.3 Testcontainers 解决什么 ​

单元测试:假模型 + H2          → 快,但 SQL 方言差异测不到
集成测试:假模型 + 真 PostgreSQL → 慢一点,但能测到真实建表、索引、并发

用 H2 测 PostgreSQL 代码,会遇到方言不兼容:JSONB 类型、窗口函数、ON CONFLICT 语法在 H2 里行为不同。上线才发现建表失败是典型事故。Testcontainers 用真实数据库镜像,把这些差异提前暴露。

提示:Testcontainers 需要本地 Docker。Windows 上用 Docker Desktop + WSL2 后端;Linux 上直接可用。CI 上需要 DinD 或挂载 Docker socket,配置前先确认 CI 环境支持。

4.4 冒烟测试怎么控制成本 ​

手段做法
低频独立 profile,CI 上每天跑一次,不进 PR 门禁
低量每次最多 5 个用例,每个 maxTokens 设 50
有上限单元测试里断言单次成本估算低于阈值
可跳过没有 Key 时自动 skip,不失败

5. 代码走查 ​

5.1 假 ChatModel ​

java
// ch01-basics/src/test/java/com/aitech/basics/support/FakeChatModel.java
public class FakeChatModel implements ChatModel {

    private final Function<Prompt, String> responder;

    public FakeChatModel(Function<Prompt, String> responder) {
        this.responder = responder;
    }

    /** 常用工厂:固定回答 */
    public static FakeChatModel answering(String text) {
        return new FakeChatModel(p -> text);
    }

    /** 常用工厂:回显用户最后一条消息,便于断言 */
    public static FakeChatModel echo() {
        return new FakeChatModel(FakeChatModel::lastUserText);
    }

    @Override
    public ChatResponse call(Prompt prompt) {
        String text = responder.apply(prompt);
        return new ChatResponse(List.of(
                new Generation(new AssistantMessage(text))));
    }

    @Override
    public ChatOptions getDefaultOptions() {
        return OpenAiChatOptions.builder().model("fake").temperature(0.0).build();
    }

    private static String lastUserText(Prompt p) {
        return p.getInstructions().isEmpty() ? "" : p.getInstructions().getLast().getText();
    }
}

5.2 单元测试:测编排逻辑,不测模型 ​

java
// ch01-basics/src/test/java/com/aitech/basics/service/AssistantServiceTest.java
class AssistantServiceTest {

    @Test
    void 应把用户输入原样传给模型() {
        var model = FakeChatModel.echo();
        var client = ChatClient.builder(model).build();
        var service = new AssistantService(client);

        String out = service.ask("你好");

        assertThat(out).isEqualTo("你好");
    }

    @Test
    void 结构化输出应能清洗代码块标记() {
        // 模拟模型返回带 ```json 的脏输出
        var model = FakeChatModel.answering(
                "```json\n{\"title\":\"支付超时\",\"priority\":\"HIGH\"}\n```");
        var client = ChatClient.builder(model).build();

        Ticket t = new TicketExtractor(client).extract("...");

        assertThat(t.title()).isEqualTo("支付超时");
        assertThat(t.priority()).isEqualTo(Ticket.Priority.HIGH);
    }

    @Test
    void 模型返回非法内容时应走兜底而不是抛异常() {
        var model = FakeChatModel.answering("这不是 JSON");
        var client = ChatClient.builder(model).build();

        Ticket t = new TicketExtractor(client).extract("...");

        assertThat(t).isNotNull();                 // 关键:有兜底
        assertThat(t.priority()).isEqualTo(Ticket.Priority.MEDIUM);
    }
}

第二个用例是这节的精髓:用假模型精确复现「模型返回脏输出」这个线上高频场景,验证清洗逻辑。用真实模型反而测不了——你没法让它稳定地返回带代码块的脏数据。

5.3 集成测试:Testcontainers + 真数据库 ​

java
// ch01-basics/src/test/java/com/aitech/basics/memory/ChatMemoryIT.java
@Testcontainers
@SpringBootTest
class ChatMemoryIT {

    @Container
    static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("chat")
            .withUsername("sa")
            .withPassword("sa");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", pg::getJdbcUrl);
        r.add("spring.datasource.username", pg::getUsername);
        r.add("spring.datasource.password", pg::getPassword);
    }

    @Autowired ChatMemory memory;

    @Test
    void 记忆应能跨实例读取() {
        memory.add("conv-1", List.of(new UserMessage("我叫木鱼")));

        List<Message> loaded = memory.get("conv-1");

        assertThat(loaded).extracting(Message::getText).contains("我叫木鱼");
    }

    @Test
    void 不同会话的记忆应互相隔离() {
        memory.add("conv-a", List.of(new UserMessage("A 的内容")));
        memory.add("conv-b", List.of(new UserMessage("B 的内容")));

        assertThat(memory.get("conv-a")).extracting(Message::getText)
                .contains("A 的内容").doesNotContain("B 的内容");
    }
}

第二个用例专门测「会话隔离」——这正是 01-06 里提到的越权风险点。用真数据库跑一次,能验证建表语句、字段类型、索引是否都正确。

5.4 冒烟测试:低频、有预算 ​

java
// ch01-basics/src/test/java/com/aitech/basics/smoke/RealModelSmokeTest.java
@EnabledIfEnvironmentVariable(named = "SMOKE_TEST", matches = "true")
@SpringBootTest
class RealModelSmokeTest {

    @Autowired ChatClient chatClient;

    @Test
    void 模型应仍能遵循结构化输出Schema() {
        var converter = new BeanOutputConverter<>(Ticket.class);
        String raw = chatClient.prompt()
                .user(u -> u.text("{format}\n{desc}")
                        .param("format", converter.getFormat())
                        .param("desc", "支付网关大量超时,用户无法下单"))
                .options(OpenAiChatOptions.builder().temperature(0.0).maxTokens(80).build())
                .call().content();

        Ticket t = converter.convert(raw);
        assertThat(t.priority()).isNotNull();        // 只断言结构不断言内容
    }
}
bash
# 只在 CI 的定时任务里跑
SMOKE_TEST=true mvn -q test -Dtest=RealModelSmokeTest

6. 跑起来 ​

bash
git checkout ch01-11-integration-test

# 1. 单元测试 + 集成测试(不花钱,首次会拉 Docker 镜像)
mvn test

# 2. 只看集成测试
mvn test -Dtest='*IT'

# 3. 冒烟测试(要花钱,按需)
SMOKE_TEST=true mvn test -Dtest=RealModelSmokeTest

期望输出:

[INFO] Tests run: 24, Failures: 0, Errors: 0
[INFO] 集成测试启动 PostgreSQL 容器 ... 就绪耗时 6.2s
检查项通过标准
mvn test 无 Key 也能跑全部通过,不依赖环境变量
耗时单元测试 < 10s,集成测试 < 60s
真实花费mvn test 期间账单无新增
异常路径覆盖脏输出、429、超时三类各有对应用例

验证「不花钱」的方法:跑之前记一下厂商控制台的用量,跑完再看一次。这一步能让你确信 CI 里真的不会产生费用。

7. 生产避坑 ​

  1. 不要让单元测试依赖真实模型,哪怕只有一个。一旦开了口子,用例数量会失控增长,几个月后 CI 每次跑几十块钱,然后有人会提议关掉测试。从第一天就把边界划清。
  2. Testcontainers 在 CI 上经常起不来,常见原因是 DinD 权限或镜像拉取慢。做法:CI 上配置镜像缓存,本地开发允许跳过(用 @EnabledIf 判断 Docker 可用性)。不要让环境问题阻断本地开发。
  3. 假模型返回的内容过于「干净」,会掩盖解析 bug。建议专门准备几个「脏样本」:带代码块标记、带前后缀说明、字段缺失、枚举大小写不符。这些样本应该来自真实线上日志里出现过的失败案例,价值远高于凭空构造的数据。

8. 延伸与锚点 ​