AI应用测试体系构建:从单元测试到端到端验证
2026/7/29大约 7 分钟AI测试单元测试集成测试Spring AIMockJava
AI应用测试体系构建:从单元测试到端到端验证
一、没有测试的AI系统,是不定时炸弹
2025年7月,某公司的智能合同审查系统悄悄上了一个"小功能"——把主力模型从GPT-4o换成了Claude 3.5。
换模型这件事,开发同学觉得只是改了一个配置项,没有跑任何测试。毕竟,合同审查的逻辑没有变,只是换了个更便宜的模型。
上线第二天,客户反馈:AI开始把合同里的"甲方免责"条款标记为"高风险条款"了,但实际上这些是合法的、常见的商业条款。
问题在于:GPT-4o和Claude 3.5对"高风险"的理解和表述方式不一样,Prompt没有更新,但模型对Prompt的响应模式变了。
这个问题直接导致客户投诉升级,公司紧急回滚,损失了一个大客户续费的机会。
AI系统的测试,比传统软件系统难在哪里?
传统软件:给定输入,期望输出是确定的。 AI系统:给定输入,输出是概率性的,且依赖于模型版本、Prompt细节、Temperature设置。
这个不确定性,让很多团队放弃了测试。他们的理由是:"AI的输出没法精确验证,所以没办法写测试。"
这是一个错误的结论。AI输出虽然不能精确验证,但可以模糊验证:验证输出包含某些关键词、验证输出格式正确、验证输出不包含有害内容。
二、AI应用测试的层次
三、单元测试:Mock掉LLM,专注业务逻辑
3.1 Spring AI 的 Mock 支持
Spring AI 内置了测试支持,可以用 MockChatModel 替换真实模型:
/**
* 使用MockChatModel进行单元测试
* 不需要真实的LLM API Key,测试速度快,成本为零
*/
@SpringBootTest
class ContractAnalysisServiceTest {
@MockBean
private ChatModel chatModel;
@Autowired
private ContractAnalysisService contractAnalysisService;
@Test
void shouldIdentifyHighRiskClause_whenContractContainsUnilateralTermination() {
// Given:模拟LLM返回特定格式的风险分析结果
String mockResponse = """
{
"riskLevel": "HIGH",
"riskClauses": [
{
"clauseId": "3.2",
"content": "甲方可单方面终止合同",
"riskType": "UNILATERAL_TERMINATION",
"suggestion": "建议增加违约赔偿条款"
}
],
"overallScore": 35
}
""";
when(chatModel.call(any(Prompt.class)))
.thenReturn(new ChatResponse(List.of(
new Generation(new AssistantMessage(mockResponse)))));
// When:调用合同分析服务
ContractRiskReport report = contractAnalysisService.analyze(
"乙方同意甲方可单方面终止合同,无需支付任何补偿。");
// Then:验证业务逻辑正确处理了LLM的输出
assertThat(report.getRiskLevel()).isEqualTo(RiskLevel.HIGH);
assertThat(report.getRiskClauses()).hasSize(1);
assertThat(report.getRiskClauses().get(0).getRiskType())
.isEqualTo("UNILATERAL_TERMINATION");
assertThat(report.getOverallScore()).isLessThan(50);
}
@Test
void shouldReturnLowRisk_whenContractIsStandard() {
// Given:模拟正常合同的分析结果
when(chatModel.call(any(Prompt.class)))
.thenReturn(new ChatResponse(List.of(
new Generation(new AssistantMessage("""
{
"riskLevel": "LOW",
"riskClauses": [],
"overallScore": 85
}
""")))));
ContractRiskReport report = contractAnalysisService.analyze(
"本合同依据公平、平等、诚实信用原则订立...");
assertThat(report.getRiskLevel()).isEqualTo(RiskLevel.LOW);
assertThat(report.getRiskClauses()).isEmpty();
}
@Test
void shouldHandleParseError_whenLLMReturnsInvalidJSON() {
// Given:模拟LLM返回格式错误的响应(真实会发生!)
when(chatModel.call(any(Prompt.class)))
.thenReturn(new ChatResponse(List.of(
new Generation(new AssistantMessage(
"该合同整体风险较低,无明显问题。"))))); // 纯文本,不是JSON
// Then:服务应该优雅降级,而不是抛出NPE
assertThatCode(() -> contractAnalysisService.analyze("..."))
.doesNotThrowAnyException();
ContractRiskReport report = contractAnalysisService.analyze("...");
assertThat(report.getRiskLevel()).isEqualTo(RiskLevel.UNKNOWN);
assertThat(report.getParseError()).isNotNull();
}
}3.2 测试Prompt构建逻辑
/**
* 测试Prompt构建逻辑
* 确保关键信息被正确注入到Prompt中
*/
@ExtendWith(MockitoExtension.class)
class PromptBuilderTest {
@InjectMocks
private ContractPromptBuilder promptBuilder;
@Test
void shouldIncludeContractTypeInPrompt() {
// Given
ContractAnalysisRequest request = ContractAnalysisRequest.builder()
.contractType("劳动合同")
.contractContent("本合同由甲方(雇主)和乙方(员工)签订...")
.analysisDepth(AnalysisDepth.DETAILED)
.build();
// When
String builtPrompt = promptBuilder.build(request);
// Then:验证关键信息包含在Prompt中
assertThat(builtPrompt)
.contains("劳动合同")
.contains("详细分析")
.doesNotContain("{contractType}") // 确保占位符被替换了
.doesNotContain("null");
}
@Test
void shouldTruncateLongContract_whenExceedsTokenLimit() {
// Given:超长合同
String longContract = "条款内容".repeat(5000); // 非常长
ContractAnalysisRequest request = ContractAnalysisRequest.builder()
.contractType("采购合同")
.contractContent(longContract)
.build();
// When
String builtPrompt = promptBuilder.build(request);
// Then:Prompt应该被截断,不超过token限制
int estimatedTokens = promptBuilder.estimateTokens(builtPrompt);
assertThat(estimatedTokens).isLessThanOrEqualTo(8000);
}
}四、集成测试:测试真实的RAG管道
/**
* RAG管道集成测试
* 使用真实的向量存储(内存版)和嵌入模型(Mock版)
*/
@SpringBootTest
@ActiveProfiles("test")
class RAGPipelineIntegrationTest {
@Autowired
private RAGService ragService;
@Autowired
private VectorStore vectorStore;
@MockBean
private EmbeddingModel embeddingModel;
@BeforeEach
void setUp() {
// Mock嵌入模型:始终返回固定向量(简化测试)
when(embeddingModel.embed(anyString()))
.thenReturn(new float[]{0.1f, 0.2f, 0.3f, 0.4f});
when(embeddingModel.embed(anyList()))
.thenAnswer(inv -> {
List<String> texts = inv.getArgument(0);
return texts.stream()
.map(t -> new float[]{0.1f, 0.2f, 0.3f, 0.4f})
.collect(toList());
});
// 准备测试文档
vectorStore.add(List.of(
new Document("我们的产品支持Excel、CSV、PDF三种格式的数据导出",
Map.of("source", "product-docs", "page", "15")),
new Document("用户可以在数据页面右上角找到导出按钮",
Map.of("source", "user-guide", "page", "23")),
new Document("产品价格分为基础版(免费)、专业版(99元/月)、企业版(面议)",
Map.of("source", "pricing", "page", "1"))
));
}
@Test
void shouldRetrieveRelevantDocuments_whenQueryingAboutExport() {
// When
List<Document> retrieved = ragService.retrieveDocuments("如何导出数据?");
// Then:应该检索到与导出相关的文档
assertThat(retrieved).isNotEmpty();
assertThat(retrieved)
.anyMatch(doc -> doc.getContent().contains("导出"));
}
@Test
void shouldIncludeSourceInAnswer() {
// Given:Mock LLM返回引用了来源的回答
// When
RAGAnswer answer = ragService.ask("产品支持哪些导出格式?");
// Then:回答应包含来源引用
assertThat(answer.getContent()).isNotBlank();
assertThat(answer.getSources()).isNotEmpty();
assertThat(answer.getSources())
.anyMatch(s -> s.contains("product-docs"));
}
}五、Prompt效果评估测试套件
/**
* Prompt效果评估框架
* 用于评估Prompt变更的影响,防止Prompt退化
*/
@Component
@RequiredArgsConstructor
public class PromptEvaluationSuite {
private final ChatClient chatClient;
private final EvaluationTestCaseRepository testCaseRepo;
/**
* 运行评估套件,返回通过率
*/
public EvaluationReport runEvaluation(String promptName) {
List<EvaluationTestCase> testCases = testCaseRepo.findByPromptName(promptName);
List<EvaluationResult> results = new ArrayList<>();
for (EvaluationTestCase tc : testCases) {
String actualOutput = chatClient.prompt()
.user(tc.getInput())
.call()
.content();
boolean passed = evaluateOutput(actualOutput, tc);
results.add(new EvaluationResult(tc.getId(), tc.getInput(),
tc.getExpectedOutput(), actualOutput, passed));
}
long passCount = results.stream().filter(EvaluationResult::passed).count();
double passRate = (double) passCount / results.size();
return EvaluationReport.builder()
.promptName(promptName)
.totalCases(testCases.size())
.passedCases((int) passCount)
.passRate(passRate)
.results(results)
.evaluatedAt(LocalDateTime.now())
.build();
}
/**
* 软验证:不要求精确匹配,而是验证关键要素
*/
private boolean evaluateOutput(String actual, EvaluationTestCase tc) {
// 1. 检查必须包含的关键词
if (tc.getRequiredKeywords() != null) {
for (String keyword : tc.getRequiredKeywords()) {
if (!actual.contains(keyword)) {
return false;
}
}
}
// 2. 检查不应包含的内容(安全检查)
if (tc.getForbiddenContent() != null) {
for (String forbidden : tc.getForbiddenContent()) {
if (actual.contains(forbidden)) {
return false;
}
}
}
// 3. 检查输出格式(如果是JSON输出)
if (tc.getExpectedFormat() == OutputFormat.JSON) {
try {
new ObjectMapper().readTree(actual);
} catch (Exception e) {
return false;
}
}
// 4. 检查输出长度
if (tc.getMaxLength() != null && actual.length() > tc.getMaxLength()) {
return false;
}
return true;
}
}六、持续集成中的AI测试
# .github/workflows/ai-tests.yml
name: AI Application Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run unit tests (no API key needed)
run: mvn test -Dtest="*UnitTest" -DLLM_MOCK=true
integration-tests:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Run integration tests (uses real API)
run: mvn test -Dtest="*IntegrationTest"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
prompt-evaluation:
runs-on: ubuntu-latest
if: contains(github.event.head_commit.message, '[eval]')
steps:
- name: Run prompt evaluation suite
run: mvn test -Dtest="PromptEvaluationTest"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}七、AI测试分层架构
八、总结
| 测试类型 | 何时运行 | 是否需要API Key | 目的 |
|---|---|---|---|
| 单元测试 | 每次提交 | 否(Mock) | 验证业务逻辑 |
| 集成测试 | 合并到main | 是 | 验证组件协作 |
| Prompt评估 | 改Prompt时 | 是 | 验证效果不退化 |
| 端到端测试 | 上线前 | 是 | 验证完整用户场景 |
AI应用的测试不是奢侈品,是保证系统可靠性的必要投资。
