第1712篇:属性测试(Property-Based Testing)在AI输出验证中的应用
第1712篇:属性测试(Property-Based Testing)在AI输出验证中的应用
某次我们上线了一个文本摘要功能,单元测试写了几十条,覆盖了各种场景。上线之后运行两天,某个用户输了一篇特别短的文章(才50个字),摘要比原文还长——这事你说可不可笑。我当时看着那个测试套件发呆:我们测了100字的、500字的、1000字的、中文的、英文的……就是没测过那种极端情况。
属性测试(Property-Based Testing)就是用来解决这类问题的。
一、传统示例测试的死角
先来看看我们通常怎么写AI输出验证的测试:
@Test
void testSummaryGeneration() {
String text = "人工智能技术正在快速发展,各大科技公司纷纷投入大量资源...";
String summary = summaryService.generate(text);
assertThat(summary).isNotBlank();
assertThat(summary.length()).isLessThan(text.length());
}这个测试能通过,但它只测了一个固定输入。你很难枚举所有边界情况——空字符串、超长文本、全是标点、全是数字、中英文混合、含有特殊字符……
传统测试(Example-Based Testing)的逻辑是:给定具体的输入,验证具体的输出。
属性测试的逻辑是:给定任意符合条件的输入,某个属性永远成立。
举个例子,对于摘要功能,有这样几个属性永远应该成立:
- 摘要长度 <= 原文长度(否则不叫摘要了)
- 摘要不能是空字符串(输入非空时)
- 摘要应该是原文语言(中文输入,输出应该是中文)
这些属性不管输入是什么,都应该成立。属性测试框架会自动生成大量随机输入,逐一验证这些属性。
二、Java中的属性测试框架
Java生态里主流的属性测试框架是 jqwik,比Junit-Quickcheck更活跃,API也更现代。
Maven依赖:
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik-spring</artifactId>
<version>0.14.0</version>
<scope>test</scope>
</dependency>三、基础概念:Arbitraries与Properties
jqwik的核心是两个概念:
- Arbitrary:数据生成器,描述"什么样的数据是合法的"
- Property:属性,描述"对于所有合法输入,某条规则成立"
快速入门:
import net.jqwik.api.*;
class BasicPropertyTest {
// 最简单的属性测试:任意整数的平方都是非负数
@Property
boolean squaresAreNonNegative(@ForAll int n) {
return (long) n * n >= 0;
}
// 带约束的属性测试
@Property
void stringReverseProperty(@ForAll @StringLength(min = 1, max = 100) String s) {
String reversed = new StringBuilder(s).reverse().toString();
// 属性1:长度不变
assertThat(reversed.length()).isEqualTo(s.length());
// 属性2:翻转再翻转等于原始
assertThat(new StringBuilder(reversed).reverse().toString()).isEqualTo(s);
}
}四、实战:AI输出的属性定义
4.1 文本摘要的属性
@ExtendWith(JqwikSpringExtension.class)
class SummaryServicePropertyTest {
@Autowired
private SummaryService summaryService;
@MockBean
private LlmClient llmClient;
@BeforeEach
void setupMock() {
// 用一个简单的截断逻辑模拟LLM行为,专注于测试属性
when(llmClient.complete(any())).thenAnswer(inv -> {
String prompt = inv.getArgument(0, String.class);
// 提取原文(简化处理)
int textStart = prompt.lastIndexOf("原文:") + 3;
String originalText = prompt.substring(textStart).trim();
// 模拟LLM返回前1/3内容作为摘要
int summaryLen = Math.max(10, originalText.length() / 3);
return originalText.substring(0, Math.min(summaryLen, originalText.length()));
});
}
// 属性1:摘要长度不超过原文
@Property(tries = 200)
void summaryIsShorterThanOriginal(
@ForAll @StringLength(min = 50, max = 2000)
@CharRange(from = '\u4e00', to = '\u9fa5') // 中文字符范围
String chineseText) {
String summary = summaryService.summarize(chineseText);
assertThat(summary.length())
.as("摘要长度(%d)不应超过原文长度(%d)", summary.length(), chineseText.length())
.isLessThanOrEqualTo(chineseText.length());
}
// 属性2:非空输入产生非空摘要
@Property(tries = 100)
void nonEmptyInputProducesNonEmptySummary(
@ForAll @StringLength(min = 10, max = 500) String text) {
String summary = summaryService.summarize(text);
assertThat(summary).isNotBlank();
}
// 属性3:摘要是稳定的(同一输入多次调用结果相同,前提是非流式)
@Property(tries = 50)
void summarizationIsDeterministic(
@ForAll @StringLength(min = 20, max = 200) String text) {
String summary1 = summaryService.summarize(text);
String summary2 = summaryService.summarize(text);
// 对于缓存场景,两次调用结果应该一致
assertThat(summary1).isEqualTo(summary2);
}
}4.2 情感分析的属性
class SentimentAnalysisPropertyTest {
@Autowired
private SentimentAnalysisService sentimentService;
@MockBean
private LlmClient llmClient;
// 情感得分始终在合法范围内
@Property(tries = 300)
void sentimentScoreIsInValidRange(
@ForAll @StringLength(min = 1, max = 1000) String text) {
SentimentResult result = sentimentService.analyze(text);
assertThat(result.getScore())
.isBetween(0.0, 1.0);
}
// 情感标签与得分方向一致
@Property(tries = 200)
void sentimentLabelConsistentWithScore(
@ForAll @StringLength(min = 5, max = 500) String text) {
SentimentResult result = sentimentService.analyze(text);
if ("positive".equals(result.getLabel())) {
assertThat(result.getScore()).isGreaterThan(0.5);
} else if ("negative".equals(result.getLabel())) {
assertThat(result.getScore()).isLessThan(0.5);
}
// neutral可以在0.4-0.6区间
}
// 情感标签只能是预定义值
@Property(tries = 100)
void sentimentLabelIsValid(@ForAll String text) {
Assume.that(!text.isBlank()); // 跳过空字符串输入
SentimentResult result = sentimentService.analyze(text);
assertThat(result.getLabel())
.isIn("positive", "negative", "neutral");
}
}五、自定义Arbitrary:模拟真实世界的AI输入
内置的Arbitrary不够用时,要自己写。AI应用的输入往往有特殊结构。
class AiInputArbitraries {
// 生成模拟用户问题的Arbitrary
static Arbitrary<String> userQuestions() {
Arbitrary<String> questionWords = Arbitraries.of(
"什么是", "如何", "为什么", "能否", "请介绍", "帮我解释"
);
Arbitrary<String> topics = Arbitraries.of(
"机器学习", "神经网络", "大语言模型", "RAG技术",
"向量数据库", "Prompt工程", "AI安全"
);
Arbitrary<String> suffixes = Arbitraries.of(
"?", ",请详细说明。", ",举个例子。", ",用简单语言解释。", ""
);
return Combinators.combine(questionWords, topics, suffixes)
.as((q, t, s) -> q + t + s);
}
// 生成带结构的文档输入
static Arbitrary<DocumentInput> documentInputs() {
Arbitrary<String> titles = Arbitraries.strings()
.withCharRange('a', 'z')
.ofMinLength(3).ofMaxLength(50);
Arbitrary<String> contents = Arbitraries.strings()
.ofMinLength(100).ofMaxLength(5000);
Arbitrary<String> languages = Arbitraries.of("zh", "en", "ja");
return Combinators.combine(titles, contents, languages)
.as((title, content, lang) ->
new DocumentInput(title, content, lang));
}
// 生成边界条件输入(专门用于压力测试)
static Arbitrary<String> edgeCaseTexts() {
return Arbitraries.oneOf(
Arbitraries.just(""), // 空字符串
Arbitraries.just(" "), // 空白字符
Arbitraries.strings().ofLength(10000), // 超长文本
Arbitraries.just("。。。。。。"), // 全是标点
Arbitraries.just("12345678901234567890"), // 全是数字
Arbitraries.strings()
.withChars('\u4e00', '\u9fa5') // 纯中文
.ofMinLength(1).ofMaxLength(100),
Arbitraries.strings()
.withCharRange('A', 'Z') // 纯大写英文
.ofMinLength(1).ofMaxLength(100)
);
}
}在测试中使用自定义Arbitrary:
class CustomArbitraryTest {
@Provide
Arbitrary<String> userQuestions() {
return AiInputArbitraries.userQuestions();
}
@Property(tries = 150)
void questionAnsweringAlwaysResponds(
@ForAll("userQuestions") String question) {
QaResult result = qaService.answer(question);
// 属性:任何问题都必须给出响应,不能返回null
assertThat(result).isNotNull();
assertThat(result.getAnswer()).isNotNull();
assertThat(result.getConfidence()).isBetween(0.0, 1.0);
}
@Provide
Arbitrary<String> edgeCases() {
return AiInputArbitraries.edgeCaseTexts();
}
@Property(tries = 50)
void serviceHandlesEdgeCasesGracefully(
@ForAll("edgeCases") String edgeInput) {
// 属性:边界输入不能导致系统崩溃(只能返回错误响应或空结果)
assertThatCode(() -> {
try {
summaryService.summarize(edgeInput);
} catch (InvalidInputException e) {
// 抛出特定业务异常是可以接受的
}
}).doesNotThrowAnyException(); // 不能抛出非预期异常
}
}六、Shrinking:找到最小失败用例
属性测试框架的神器是 Shrinking。当随机生成的某个输入导致测试失败时,框架会自动尝试"缩小"这个输入,找到最小的能复现问题的用例。
jqwik默认开启Shrinking。看一个例子:
@Property
void summaryLengthProperty(@ForAll @StringLength(min = 1, max = 500) String text) {
String summary = summaryService.summarize(text);
// 故意写一个有问题的断言
assertThat(summary.length()).isLessThan(10);
}如果这个测试失败,jqwik不会直接告诉你哪个500字的随机文本导致了失败,它会不断缩短那个文本,最终给你类似这样的报告:
Falsified and Shrunk after 23 tries.
Original: "人工智能技术正在快速发展,各大科技公司纷纷投入..."(300字)
Shrunk: "人工智能"(5字)这让调试变得极其方便。
自定义Shrinking行为(高级用法):
// 自定义Arbitrary支持Shrinking
class AiPromptArbitrary implements Arbitrary<AiPrompt> {
@Override
public RandomGenerator<AiPrompt> generator(int genSize) {
return random -> {
String template = randomTemplate(random);
Map<String, String> params = randomParams(random);
return Shrinkable.unshrinkable(new AiPrompt(template, params));
};
}
// 实现Shrinking逻辑:减少参数数量
@Override
public Optional<ExhaustiveGenerator<AiPrompt>> exhaustive(long maxNumberOfSamples) {
return Optional.empty(); // 不支持穷举
}
}七、状态属性测试:验证AI系统的行为一致性
有些属性不是单次调用就能验证的,需要验证一系列操作的组合效果。
@Label("AI对话历史一致性属性")
class ConversationPropertyTest {
@Autowired
private ConversationService conversationService;
// 属性:无论对话多少轮,历史消息条数应该严格递增
@Property(tries = 50)
void conversationHistoryGrowsMonotonically(
@ForAll @Size(min = 1, max = 10) List<@StringLength(min = 1, max = 50) String> messages) {
String sessionId = UUID.randomUUID().toString();
int expectedCount = 0;
for (String message : messages) {
conversationService.sendMessage(sessionId, message);
expectedCount += 2; // 每轮对话:用户消息 + AI回复
int actualCount = conversationService.getHistory(sessionId).size();
assertThat(actualCount)
.as("发送%d条消息后,历史应该有%d条", messages.indexOf(message) + 1, expectedCount)
.isEqualTo(expectedCount);
}
}
// 属性:清除历史后,历史为空
@Property(tries = 30)
void clearHistoryAlwaysResultsInEmptyHistory(
@ForAll @Size(min = 1, max = 5) List<String> messages) {
String sessionId = UUID.randomUUID().toString();
messages.forEach(m -> conversationService.sendMessage(sessionId, m));
conversationService.clearHistory(sessionId);
assertThat(conversationService.getHistory(sessionId)).isEmpty();
}
}八、与Mock LLM结合的属性测试
关键技巧:属性测试配合Mock LLM,让LLM的行为可控,专注于测试业务逻辑属性。
class MockedLlmPropertyTest {
// 构造一个"可以返回任意合法JSON"的Mock LLM
private LlmClient buildMockLlm(Supplier<String> responseSupplier) {
LlmClient mock = mock(LlmClient.class);
when(mock.complete(any())).thenAnswer(inv -> responseSupplier.get());
return mock;
}
@Provide
Arbitrary<String> validSentiments() {
return Arbitraries.of("positive", "negative", "neutral");
}
@Provide
Arbitrary<Double> validScores() {
return Arbitraries.doubles().between(0.0, 1.0);
}
@Property(tries = 100)
void parserHandlesAllValidLlmOutputFormats(
@ForAll("validSentiments") String sentiment,
@ForAll("validScores") double score,
@ForAll @Size(min = 0, max = 5) List<@StringLength(min = 1, max = 20) String> keywords) {
// 构造合法的LLM响应JSON
String llmResponse = String.format(
"{\"sentiment\":\"%s\",\"score\":%.2f,\"keywords\":[%s]}",
sentiment, score,
keywords.stream().map(k -> "\"" + k + "\"").collect(Collectors.joining(","))
);
LlmClient mockLlm = buildMockLlm(() -> llmResponse);
SentimentParser parser = new SentimentParser(mockLlm);
// 属性:对于所有合法的LLM输出,解析器不应该抛出异常
assertThatCode(() -> {
SentimentResult result = parser.parse("任意输入文本");
// 进一步验证解析结果的属性
assertThat(result.getLabel()).isIn("positive", "negative", "neutral");
assertThat(result.getScore()).isBetween(0.0, 1.0);
}).doesNotThrowAnyException();
}
}九、踩坑:属性测试的常见误区
误区1:tries设置太少
默认100次往往不够。对于AI输出验证,建议设置到至少500次,边界情况才容易覆盖到:
@Property(tries = 500, shrinking = ShrinkingMode.FULL)
void importantProperty(@ForAll String input) { ... }误区2:Assume使用过度
Assume.that(condition) 用于跳过不满足前置条件的输入,但如果用太多,实际有效的测试次数会大幅下降:
// 不好的写法
@Property(tries = 100)
void test(@ForAll String text) {
Assume.that(text.length() > 10); // 跳过太多了
Assume.that(text.contains("AI")); // 这个条件太严苛,可能99%都被跳过
// 实际上只有不到1%的输入真的被测试
}
// 好的写法:直接生成满足条件的数据
@Property(tries = 100)
void test(@ForAll @StringLength(min = 11, max = 500) String text) {
// 直接生成长度>10的字符串
}误区3:属性定义不精确
属性太宽泛(比如"不抛异常")测不出真正的问题,太严格(比如具体的输出值)又变成示例测试了。好的属性在结构上约束,在值上宽容。
十、整合报告
属性测试结果可以集成到测试报告里,关键指标:
@Property(tries = 500, shrinking = ShrinkingMode.FULL)
@Report(Reporting.GENERATED) // 输出每次生成的测试数据(调试用)
@StatisticsReport(format = NumberRangeHistogram.class)
void statisticsExample(@ForAll @IntRange(min = 0, max = 100) int value) {
Statistics.label("value range")
.collect(value < 30 ? "small" : value < 70 ? "medium" : "large");
// 你的属性断言
assertThat(someFunction(value)).isNotNull();
}这会输出测试数据的分布统计,帮你确认边界情况确实被覆盖到了。
总结
属性测试的价值不在于替换示例测试,而在于补充那些你"没想到"的边界情况。
在AI应用里,属性测试特别适合:
- 验证输出结构的约束(字段存在、类型正确、值域合法)
- 验证服务的不变性(幂等性、单调性、对称性)
- 验证解析器的健壮性(对各种合法输入都能正确处理)
不适合的场景:验证具体的AI输出内容质量(那是评估器的活)。
属性测试写起来比示例测试难一些,需要你认真思考"这个功能的本质属性是什么"。但这个思考过程本身就很有价值——它逼着你更清楚地理解你在构建什么。
