Java 基础体系 · 第 83/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 属性测试与模糊测试:生成器、不变量、缩减和回归
属性测试(Property-based Testing)不是为若干个手工样例编写若干个断言,而是先描述一条对大量输入都应成立的性质,再由测试系统自动生成输入、执行性质、报告失败样例,并尽量把失败样例缩减为更容易理解的最小反例。
模糊测试(Fuzzing)则是更大的测试技术集合:它持续向程序输入大量自动生成、变异或覆盖引导的数据,重点发现崩溃、异常退出、超时、资源耗尽、解析错误和安全边界问题。属性测试通常关注“结果是否满足性质”,模糊测试既可以关注性质,也可以只关注“程序是否安全地处理输入”。
本文围绕四个核心机制展开:
- 生成器(generator):如何构造输入,以及如何保证输入仍然属于有效测试域。
- 不变量(invariant):如何把需求写成对任意输入或任意状态都成立的条件。
- 缩减(shrinking):失败后如何从复杂输入逐步得到更小、更清晰的反例。
- 回归(regression):如何保存失败证据,使修复后的行为不会再次退化。
这些机制可以在 Java 25 LTS 项目中使用。Java SE 本身没有提供属性测试或模糊测试框架;java.util.random、集合、字符串、记录类等标准能力可以用来构造测试基础设施,而完整的属性测试和模糊测试通常依赖外部测试框架。
一、先区分示例测试、属性测试和模糊测试
1. 示例测试验证有限个具体事实
传统单元测试通常直接给出输入和期望输出:
assertEquals("[1, 2, 3]", normalize("[ 1, 2, 3 ]"));
它验证的是一个具体事实:
这种测试适合表达业务规则明确、样例具有代表性的场景。但它只能覆盖被写出来的输入。即使实现对正常样例都正确,也可能在以下输入上失败:
- 空输入;
- 只有一个元素;
- 负数;
- 重复值;
- 极大长度;
- 非 ASCII 字符;
null;- 溢出边界;
- 不同状态顺序。
2. 属性测试验证输入集合上的普遍性质
属性测试不一定知道每个输入的精确期望值,而是验证某个性质对所有合法输入成立:
其中:
- 是测试输入域;
- 是生成器生成的一个输入;
- 是被测程序和断言共同定义的性质。
例如,对排序函数 sort,可以写出:
和:
第一条表示输出有序,第二条表示输出与原输入包含相同的元素,只是顺序可能发生变化。
这两条性质比“输入 [3, 1, 2] 应输出 [1, 2, 3]”更一般。它们不要求为每一种输入手工编写期望结果。
3. 模糊测试强调输入探索和故障发现
模糊测试的典型目标是:
它经常测试:
- JSON、XML、压缩包、图片、协议帧等解析器;
- 编码和解码器;
- 网络协议实现;
- 文件格式处理逻辑;
- 认证、权限和输入校验代码;
- 对异常输入敏感的 native 或 JVM 互操作代码。
因此,模糊测试可以有性质:
对于所有生成的字节序列,解析器不得导致进程崩溃。
也可以没有完整的业务 oracle,只检查:
- 是否抛出允许的输入异常;
- 是否在时间限制内结束;
- 是否不产生非法内存访问;
- 是否不超过内存、文件描述符或线程资源限制。
属性测试更强调“关系和不变量”,模糊测试更强调“广泛探索输入空间和异常路径”。两者可以重叠,但不是同义词。
二、生成器:测试质量首先取决于输入域
1. 生成器不仅是随机数函数
生成器是一个从随机源到测试输入的函数:
其中:
- 是随机状态或随机字节流;
- 是测试输入域;
- 返回一个输入。
简单的整数生成器可以是:
int value = random.nextInt();
但真实系统通常需要结构化输入。例如,测试一个订单解析器时,输入可能必须满足:
订单
├── 订单号
├── 用户
├── 创建时间
└── 商品列表
├── 商品编号
├── 数量
└── 单价
如果完全随机生成字节,大多数输入可能在最外层就无法解析,测试只能反复验证“输入格式非法”。这对健壮性测试有价值,但无法深入业务逻辑。
更有效的生成器通常分层:
- 生成合法结构;
- 在合法结构上改变边界值;
- 有意注入局部非法字段;
- 控制规模、深度和嵌套层级;
- 记录生成参数,便于回放。
2. 生成器必须明确“有效域”
设一个方法:
Money add(Money left, Money right)
如果 Money 的金额必须满足:
scale <= 2
currency != null
amount 不超过允许范围
那么生成器不能只生成任意 BigDecimal。否则测试失败可能来自测试数据违反构造约束,而不是被测逻辑错误。
可以把生成器看成带有前置条件的函数:
测试系统还可以单独生成无效输入:
两类生成器的断言不同:
- 对合法输入,通常验证业务后置条件;
- 对非法输入,通常验证明确的拒绝方式、异常类型和资源边界。
常见误区是把“无法解析”都当成无价值样例。对于语法解析器,非法输入本身就是重要测试域;但如果目标是测试解析后的业务语义,就必须提高合法结构输入的比例。
3. 边界值应当是生成策略的一部分
纯随机生成对边界覆盖并不可靠。例如,随机生成一个 32 位整数,恰好得到 Integer.MIN_VALUE 的概率极低。有效生成器通常会混合:
普通值:70%
零、空字符串、空集合:10%
最小值和最大值:10%
刚好越界的值:10%
边界集合应根据被测代码决定,例如:
List<Integer> important = List.of(
Integer.MIN_VALUE,
Integer.MIN_VALUE + 1,
-1,
0,
1,
Integer.MAX_VALUE - 1,
Integer.MAX_VALUE
);
这不是“随机性越强越好”。测试输入的目标是覆盖风险结构,而不是产生不可解释的噪声。
4. 生成器必须控制复杂度
递归数据结构的生成器有一个实际问题:如果每个节点都随机生成多个子节点,树的规模可能指数增长。
假设每个节点平均产生 个子节点,递归深度为 ,节点数量近似为:
当 时,深度稍微增加就可能产生非常大的输入。结果可能是:
- 测试时间暴涨;
- 缩减过程极慢;
- 堆内存耗尽;
- 失败样例包含数万层嵌套,难以诊断。
因此,生成器需要显式传递规模参数 size 或 depth,并在递归时减少它:
Node generate(int depth) {
if (depth <= 0) {
return new Leaf(randomValue());
}
if (random.nextBoolean()) {
return new Leaf(randomValue());
}
return new Branch(
generate(depth - 1),
generate(depth - 1)
);
}
这里的 depth 不是装饰性参数,而是生成复杂度上界的一部分。
三、不变量:从“期待输出”转向“必须始终成立”
1. 不变量的定义
不变量是程序执行前后都应保持成立的条件。对函数而言,常见形式是:
对状态机而言,常见形式是:
其中 是某个时刻的系统状态,I 表示状态不变量。
例如,队列的基本不变量可以是:
size >= 0
size == numberOfStoredElements
poll 后返回的元素是此前最早加入且尚未移除的元素
属性测试会生成多条操作序列,而不是只生成一个输入:
offer(A), offer(B), poll(), offer(C), poll()
每执行一步,都可以检查当前状态是否仍满足不变量。
2. 代数性质通常比固定答案更稳定
对于函数 ,可以验证代数关系。
幂等性
若重复应用一次与应用两次相同:
典型例子:
- 标准化;
- 去重;
- 格式化;
- 配置合并后的规范化。
往返性质
编码和解码通常应满足:
但这个公式只有在编码是无损的情况下成立。如果编码会丢失信息,就需要改写为:
例如,某格式会忽略字段顺序,那么直接比较原对象可能错误,应该比较规范化后的对象。
交换律
某些运算应满足:
加法集合并可能满足交换律,但字符串拼接不满足。把不适用的数学性质套到业务代码上,会产生错误的测试,而不是更强的测试。
单调性
对于排序或过滤函数,可以验证:
但必须先定义排序关系。字符串的字典序、自然数序、忽略大小写的序并不相同。
3. 关系式属性可以替代难以构造的 oracle
当精确答案难以独立实现时,可以比较两个实现或两个输入之间的关系。
例如,数据库查询优化前后的结果应该相同:
同一个压缩算法在不同分块方式下解压后应相同:
对输入做等价变换后,结果应保持某种关系:
这类属性称为差分属性或蜕变属性(metamorphic property)。它们尤其适用于:
- 编译器;
- 查询优化器;
- 序列化框架;
- 加密和压缩实现;
- 无法轻易写出完整期望结果的复杂算法。
4. 浮点数、异常和副作用会改变属性定义
不要直接对浮点结果使用精确相等:
assertEquals(expected, actual);
对于浮点计算,通常应定义误差:
但这对 NaN 和无穷大还不够。测试必须明确:
NaN是否允许;- 正无穷和负无穷是否有业务含义;
-0.0是否与0.0等价;- 比较是否应该使用绝对误差、相对误差或两者结合。
类似地,检查异常时不能只写:
assertThrows(Exception.class, () -> parse(input));
这会把编程错误、数据库连接错误和预期的输入拒绝混在一起。属性应区分:
非法输入 -> 指定的输入异常
合法输入 -> 不应抛出异常
资源超限 -> 在明确边界内失败
如果被测函数具有外部副作用,例如写数据库、发送消息或修改文件,那么“重复执行结果相同”的幂等性属性只有在系统本身声明幂等时才成立。
四、一个可运行的 Java 25 属性测试示例
下面的程序只使用 JDK 标准 API,保存为 PropertyDemo.java 后可以用 Java 25 编译运行:
javac --release 25 PropertyDemo.java
java PropertyDemo
示例故意实现了一个有缺陷的排序函数:
for (int j = 1; j < result.length; j++)
被错误地写成了:
for (int j = 1; j < result.length; j++)
看起来循环边界正确,但插入位置循环中的条件使用了 j > 1,导致索引为 0 的元素永远不会被比较。输入 [1, 0] 就会暴露该错误。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
public class PropertyDemo {
private static final long SEED = 25_031_025L;
public static void main(String[] args) {
Random random = new Random(SEED);
List<int[]> cases = new ArrayList<>();
// 固定样例用于演示失败和回归。
cases.add(new int[]{1, 0});
// 生成随机数组。
for (int i = 0; i < 1_000; i++) {
cases.add(generateArray(random));
}
for (int[] input : cases) {
if (!propertyHolds(input)) {
int[] minimal = shrink(input);
System.out.println("property failed");
System.out.println("seed = " + SEED);
System.out.println("original = " + Arrays.toString(input));
System.out.println("minimal = " + Arrays.toString(minimal));
System.out.println("output = "
+ Arrays.toString(buggySort(minimal)));
return;
}
}
System.out.println("all generated cases passed");
}
private static int[] generateArray(Random random) {
int length = random.nextInt(21); // 0 到 20
int[] result = new int[length];
for (int i = 0; i < result.length; i++) {
// 有意让边界值更常出现。
result[i] = switch (random.nextInt(10)) {
case 0 -> Integer.MIN_VALUE;
case 1 -> Integer.MAX_VALUE;
case 2, 3 -> 0;
default -> random.nextInt(101) - 50;
};
}
return result;
}
private static boolean propertyHolds(int[] input) {
int[] output = buggySort(input);
// 性质一:输出必须非递减。
for (int i = 1; i < output.length; i++) {
if (output[i - 1] > output[i]) {
return false;
}
}
// 性质二:排序不能改变元素多重集合。
int[] expectedElements = input.clone();
int[] actualElements = output.clone();
Arrays.sort(expectedElements);
Arrays.sort(actualElements);
return Arrays.equals(expectedElements, actualElements);
}
private static int[] buggySort(int[] input) {
int[] result = input.clone();
for (int i = 1; i < result.length; i++) {
int value = result[i];
int j = i - 1;
// BUG:应该是 j >= 0。
while (j > 0 && result[j] > value) {
result[j + 1] = result[j];
j--;
}
result[j + 1] = value;
}
return result;
}
private static int[] shrink(int[] failingInput) {
int[] current = failingInput.clone();
boolean changed;
do {
changed = false;
for (int[] candidate : shrinkCandidates(current)) {
if (!propertyHolds(candidate)) {
current = candidate;
changed = true;
break;
}
}
} while (changed);
return current;
}
private static List<int[]> shrinkCandidates(int[] input) {
List<int[]> candidates = new ArrayList<>();
// 尝试删除一个元素,减少结构规模。
for (int remove = 0; remove < input.length; remove++) {
int[] candidate = new int[input.length - 1];
System.arraycopy(input, 0, candidate, 0, remove);
System.arraycopy(
input,
remove + 1,
candidate,
remove,
input.length - remove - 1
);
candidates.add(candidate);
}
// 尝试把元素向零缩减,减少数值复杂度。
for (int i = 0; i < input.length; i++) {
if (input[i] != 0) {
int[] candidate = input.clone();
candidate[i] = input[i] / 2;
candidates.add(candidate);
}
}
return candidates;
}
}
一次可能的输出为:
property failed
seed = 25031025
original = [1, 0]
minimal = [1, 0]
output = [1, 0]
这里的测试过程不是“随机调用一个方法”这么简单,而是由几个步骤组成:
generateArray生成输入;propertyHolds执行两个独立性质;- 第一个失败输入被记录;
shrink删除元素或把数值向零缩减;- 只有候选输入仍然失败时,才接受该缩减;
- 最终输出可以稳定重放的反例。
将实现修正为:
private static int[] correctSort(int[] input) {
int[] result = input.clone();
Arrays.sort(result);
return result;
}
并让 propertyHolds 调用 correctSort,所有生成样例应通过。
这个示例中的第二个性质也很重要。如果只检查“输出有序”,一个实现把所有元素直接替换成空数组,也可能在某些测试中被错误地判定为通过。排序的正确性至少需要同时约束顺序和元素保留关系。
五、缩减:从复杂故障得到最小反例
1. 缩减不是简单截断
当一个模糊输入包含数千字节、数百个对象或很深的嵌套结构时,直接把输入截断并不一定仍然触发故障。
缩减器需要寻找一个更小的输入 ,满足:
并且:
因此,缩减本质上是一个受约束的搜索问题:
这里的 size 可以是:
- 字节数;
- 集合元素数量;
- 树节点数;
- 字符串长度;
- 操作序列长度;
- 数值绝对值;
- 嵌套深度。
2. 缩减必须保持输入域
假设测试的是合法 JSON 文档,缩减后不能随意删除任意字节,否则结果可能只是语法错误,而不是原始业务故障。
更可靠的做法是按语法树缩减:
对象
├── 删除一个字段
├── 缩减字段值
└── 缩减嵌套对象
数组
├── 删除一个元素
└── 缩减某个元素
对命令序列也应按命令边界缩减:
PUT a=1
PUT b=2
DELETE a
GET b
不能只删除任意字符,而应尝试:
- 删除一条命令;
- 删除连续的一组命令;
- 缩减命令参数;
- 保持前置条件仍成立;
- 检查故障是否仍存在。
3. 贪心缩减、分块缩减和最小性
前面的示例使用贪心算法:依次尝试候选输入,只要仍然失败就立即接受。
这种算法简单且通常足够实用,但它不保证得到全局最小解。原因是局部选择可能阻止另一条更短路径。例如:
先删除元素 A -> 仍失败
先删除元素 B -> 仍失败
删除 A 后再删除 B -> 不失败
删除 B 后再删除 A -> 仍失败
不同候选顺序可能得到不同结果。
工程中的缩减器常用以下策略组合:
- 优先删除大块结构;
- 再删除单个元素;
- 再缩小数值;
- 再缩短字符串;
- 最后处理字节级修改;
- 对每个候选重新执行故障判定。
缩减器的判定函数必须尽可能稳定。若故障只以 1% 的概率出现,缩减器很难判断候选输入是否保留了故障。此时应先提高重现性,再谈缩减。
4. 缩减器也可能改变故障类型
一个输入可能同时导致:
- 超时;
- 内存压力;
- 断言失败;
- 错误异常;
- 进程崩溃。
如果缩减器只判断“程序退出码非零”,它可能把原本的业务错误缩减成另一个无关异常。故障判定应区分类型,例如:
目标故障:抛出 IllegalStateException
非目标故障:OutOfMemoryError
非目标结果:合法返回
模糊测试中的“仍然失败”不是一个足够精确的定义。必须说明保留哪一种失败。
六、状态机属性:测试操作序列而不是单个输入
许多工程缺陷只有在操作顺序变化时才出现,例如:
- 缓存先失效再读取;
- 事务提交后重复提交;
- 连接关闭后再次发送请求;
- 队列先出队再入队;
- 先取消任务再等待结果。
可以把系统抽象为状态机:
其中:
- 是第 步之前的系统状态;
command_i是生成的一条操作;step执行操作并产生新状态;- 是新状态。
测试同时维护一个较简单的模型状态 :
然后检查系统状态与模型状态之间的关系:
例如,测试一个集合实现,可以用 HashSet 作为参考模型:
命令:add(x)
系统:被测集合 add(x)
模型:参考 Set add(x)
断言:两者返回值相同,size 相同,contains(x) 相同
状态测试的关键步骤
- 生成当前状态允许的命令;
- 执行被测对象;
- 执行参考模型;
- 比较返回值和可观察状态;
- 保存命令序列;
- 失败后缩减命令序列;
- 重放缩减后的序列。
命令生成必须考虑前置条件。例如,空队列上的 poll 可能是合法操作,也可能不允许。如果生成器不区分前置条件,测试的大部分时间可能都在验证“操作被拒绝”。
资源状态也是状态的一部分
数据库连接、线程池、文件句柄、事务和临时目录都属于状态。只检查返回值可能漏掉资源不变量:
操作前连接数 = 10
操作后连接数仍应为 10
对于失败路径尤其重要:
请求超时后,事务必须回滚;
解析失败后,临时文件必须删除;
取消任务后,不应继续提交结果。
这些属性往往比“正常路径返回成功”更能发现生产问题。
七、并发属性需要明确观察范围
并发测试不能简单地把顺序测试循环执行很多次。并发缺陷依赖:
- 线程交错;
- 内存可见性;
- 锁的持有范围;
- 中断和取消;
- 超时;
- 调度器行为;
- 共享资源生命周期。
对并发组件,常见的不变量包括:
计数器不会丢失更新;
同一个任务不会被执行两次;
关闭后的资源不会接受新操作;
所有提交的任务最终都有明确结果;
失败不会遗留锁或占用连接。
但“执行一万次没有复现”不是证明。并发测试只能增加某些调度交错被观察到的概率。
如果要验证线性化性质,需要记录每个操作的调用时间和返回时间,并寻找一个满足实时先后关系的顺序。若操作 A 在 B 开始前已经返回,则线性化顺序必须满足:
这类测试比检查最终计数值更强,因为某些错误实现可能偶然得到正确最终值,却在中间状态违反线程安全契约。
Java 内存模型由 Java 语言规范和相关平台规范定义,但属性测试框架不会自动替你证明可见性和原子性。需要使用正确的并发原语,并针对可观察的并发契约设计测试。
八、随机性、种子与可重现性
属性测试的随机性必须可控。一个失败报告至少应该包含:
测试名称
随机种子
生成次数
失败输入
运行参数
JDK 版本
操作系统和架构
被测代码版本
种子可以让生成序列重新出现,但有几个限制:
- 生成器算法改变后,同一种子不一定得到同样输入;
- Java、框架或随机算法实现改变后,序列可能变化;
- 并发测试中的线程调度不能仅靠种子完全复现;
- 时间、时区、默认字符集和外部服务也会影响结果;
- 随机输入若没有序列化保存,未来可能难以精确重放。
因此,种子用于定位和初步回放,最小失败输入用于长期回归。两者不能互相替代。
不要在测试过程中使用不可控的全局随机状态:
ThreadLocalRandom.current()
并不是绝对错误,但如果失败只记录一个业务输入,却没有记录所有影响输入生成和调度的因素,复现会困难。更好的设计是将随机源显式传入生成器。
九、回归:失败样例必须成为长期资产
1. 回归测试的对象不是“随机性”
假设一个生成器在第 8,421 次发现输入:
{"items":[{"quantity":-1}]}
如果修复后只重新运行同样数量的随机样例,不能证明这个具体缺陷不会再次出现。随机样例可能再也没有生成同样结构。
正确做法是把失败样例加入确定性回归:
@Test
void negativeQuantityIsRejected() {
String input = """
{"items":[{"quantity":-1}]}
""";
assertThrows(IllegalArgumentException.class,
() -> parseOrder(input));
}
属性测试负责探索未知空间,回归测试负责固定已知缺陷。
2. 回归样例应保存最小证据
一个有价值的失败样例通常包括:
原始输入:用于分析故障来源
缩减输入:用于快速稳定复现
失败性质:哪个不变量被违反
观察结果:实际输出、异常或退出状态
生成种子:辅助重放原始路径
如果输入是二进制数据,应保存原始字节,而不是依赖日志中的转义字符串。文本输入要明确编码,不能依赖操作系统默认字符集。
3. 回归断言应稳定而精确
不应把不稳定信息写进断言,例如:
assertEquals("error at position 17", exception.getMessage());
如果位置说明不是对外契约,这种断言会在错误消息改进后产生无意义失败。应优先断言:
- 异常类型;
- 错误类别;
- 关键错误码;
- 不变量;
- 资源状态;
- 可观察业务结果。
如果错误位置本身是协议契约,才应把它纳入回归断言。
十、模糊测试的几种输入策略
1. 基于生成(generation-based fuzzing)
直接根据语法或领域模型生成输入:
生成长度
生成字段
生成字段类型
生成嵌套结构
序列化为字节
交给目标程序
优点是容易生成合法或半合法的深层结构,适合解析器和业务协议。
缺点是需要了解输入格式,生成器写错时可能覆盖不到真实代码。
2. 基于变异(mutation-based fuzzing)
从种子输入开始修改:
- 翻转字节;
- 删除区间;
- 重复区间;
- 替换边界值;
- 修改长度字段;
- 交换字段顺序;
- 注入特殊字符。
它适合已有大量真实样本的场景,例如日志、协议包和生产文件。
变异必须理解结构,否则一个字节变化可能立即使输入无法通过最外层校验。结构感知变异可以只改变字段值,同时修正长度和校验和。
3. 覆盖引导(coverage-guided fuzzing)
覆盖引导模糊测试根据执行路径反馈保留更有价值的输入。若输入触发了新的分支、基本块或边,则把它加入语料库,后续继续变异。
其基本循环可以抽象为:
取出语料库输入
↓
变异或重新生成
↓
执行目标程序
↓
收集覆盖和故障
↓
新覆盖加入语料库
↓
故障输入保存并缩减
覆盖率不是正确性的证明。一个输入可以覆盖很多代码,却没有触发关键业务错误;相反,一条很短的输入也可能触发严重缺陷。
Java 项目通常使用外部工具实现覆盖引导或 JVM 模糊测试。具体框架的目标方法签名、依赖版本、代理参数和构建方式属于框架契约,不是 Java SE 25 或 JLS 25 的标准内容,不能把某个框架 API 当成 Java 语言保证。
4. 字节级模糊和结构化属性应组合使用
测试网络协议时,可以分两层:
- 生成结构合法的协议帧,深入测试业务处理;
- 对长度、校验和、截断和随机字节进行变异,测试拒绝路径和健壮性。
只做第一层可能漏掉恶意输入;只做第二层则大量停留在协议头解析阶段。
十一、解析器模糊测试中的故障分类
对于一个输入解析器,结果不能只分为“成功”和“失败”。至少应区分:
| 结果 | 通常含义 | 是否一定是缺陷 |
|---|---|---|
| 返回合法对象 | 输入被接受 | 需要继续检查对象不变量 |
| 抛出预期输入异常 | 输入被拒绝 | 通常不是缺陷 |
| 抛出未声明运行时异常 | 可能是实现缺陷 | 需要诊断 |
| 超时 | 可能存在算法复杂度或死循环问题 | 通常是缺陷 |
| 进程崩溃 | 严重健壮性或 native 问题 | 是缺陷 |
| 内存耗尽 | 可能是资源攻击面 | 需要结合契约判断 |
例如,一个 JSON 解析器拒绝随机二进制通常是正常的;但如果某个输入让它出现 StackOverflowError,这可能说明嵌套深度没有限制。
对于资源边界,还要记录:
输入长度
嵌套深度
解析耗时
堆内存变化
创建对象数量
线程数
否则很难判断是正常的大输入,还是拒绝服务风险。
十二、属性测试中的常见误区
1. 生成了大量输入,却没有扩大有效覆盖
如果生成器产生的 99% 输入都在第一行被拒绝,测试次数增加并不能等价于测试深度增加。
诊断方法包括:
- 统计每个解析阶段的输入数量;
- 统计异常类型分布;
- 统计关键分支命中次数;
- 查看输入规模和结构分布;
- 检查是否总是生成相同的边界形态。
2. 属性本身重复实现了被测代码
如果排序实现使用一个算法,测试 oracle 又使用几乎相同的算法,两个实现可能共享同一个错误。
更好的组合是:
- 代数性质;
- 独立的简单参考实现;
- 固定边界样例;
- 差分实现;
- 状态模型。
参考模型不一定性能高,但应尽量简单、独立、容易审查。
3. 只检查最终状态,遗漏中间状态
错误的队列实现可能最终包含正确元素,但某次 poll 返回了错误元素。状态机测试应在每个命令后检查:
返回值
异常
size
contains
迭代顺序
资源状态
最终状态断言不能替代逐步不变量。
4. 缩减破坏了原始前置条件
如果故障需要先登录再访问资源,缩减器删除“登录”命令后,最终可能只是得到一个未认证错误。此时输入虽然短了,但不再表示原始故障。
状态序列缩减必须同时验证:
只有保持命令前置条件,并且仍然触发同类故障的候选,才是有效缩减结果。
5. 把偶发环境故障当作确定性属性失败
网络服务、时间、线程调度和外部数据库会让同一个输入产生不同结果。对于这类测试,应先隔离环境:
- 使用内存或容器化依赖;
- 固定时钟;
- 控制超时;
- 使用虚拟网络;
- 记录外部响应;
- 明确重试策略。
否则缩减器和回归测试会在外部故障与代码缺陷之间反复摇摆。
十三、在 Java 25 项目中组织属性测试
Java 25 只规定语言和标准库行为,不规定测试框架的生成器、缩减器或覆盖反馈 API。项目可以按以下边界组织代码:
src/test/java/
├── unit/ 示例测试和小规模边界测试
├── property/ 生成器、不变量、状态模型
├── fuzz/ 模糊目标和输入适配器
└── regression/ 已缩减失败样例
生成器和模型应尽量是纯函数。纯函数具有几个好处:
- 随机种子更容易重放;
- 缩减时副作用更少;
- 失败判定更稳定;
- 可以在不启动外部服务的情况下执行。
涉及数据库、文件和网络时,测试输入仍应与环境解耦。例如,把“生成订单”和“提交订单”分开:
生成订单对象
↓
检查对象不变量
↓
序列化
↓
调用数据库或 HTTP 层
↓
检查返回值、状态和资源清理
这样可以分别定位:
- 生成器错误;
- 序列化错误;
- 传输错误;
- 持久化错误;
- 事务和清理错误。
测试强度不能只由次数决定
测试次数 增加后,某个概率为 的缺陷至少被命中的概率是:
当 很小时,增加次数确实有帮助;但如果生成器从不产生触发结构,则 ,无论运行多少次都不会发现缺陷。
因此,应同时改进:
- 输入域;
- 边界分布;
- 结构深度;
- 状态序列;
- 代码覆盖;
- 故障分类;
- 缩减和回归流程。
十四、一条完整的属性测试与模糊测试生命周期
flowchart TD
A[定义输入域与契约] --> B[生成或准备种子]
B --> C[执行目标程序]
C --> D{性质是否成立}
D -->|是| E[记录覆盖与统计]
E --> B
D -->|否| F[分类故障]
F --> G[保存原始输入、种子和环境]
G --> H[缩减输入或操作序列]
H --> I[重放并确认同类故障]
I --> J[加入确定性回归]
J --> K[修复实现]
K --> L[运行回归与属性测试]
关键路径是:
- 先定义契约:没有明确的不变量,就无法判断随机输入是否暴露错误。
- 再生成输入:生成器要覆盖合法、边界和有意非法的数据。
- 执行并分类:预期拒绝不能和崩溃、超时混为一谈。
- 保存证据:至少保存输入、种子和运行环境。
- 缩减并确认:缩减后的输入必须仍触发同类故障。
- 转为回归:修复后不依赖随机概率,而是确定性验证该缺陷。
- 继续探索:回归固定已知问题,属性测试和模糊测试继续寻找未知问题。
十五、如何判断一条属性是否真正有价值
一条属性至少应回答三个问题:
输入是什么
明确输入域:
任意整数数组
合法订单
带有前置条件的命令序列
任意 UTF-8 字节序列
如果输入域含糊,生成器和断言就会互相矛盾。
正确性是什么
明确是:
- 精确结果;
- 近似结果;
- 代数关系;
- 状态不变量;
- 允许异常集合;
- 时间或资源上界;
- 不崩溃条件。
“程序不应该出问题”不能直接执行,必须转换成可观察的判断。
失败后如何复现
明确记录:
- 输入;
- 操作顺序;
- 随机种子;
- JDK 和框架版本;
- 系统属性;
- 时区和编码;
- 外部依赖响应;
- 故障分类。
只有能够可靠复现和缩减,属性测试发现的失败才会从一次日志事件变成可修复的工程证据。
十六、最终边界
属性测试不能证明实现对无限输入域全部正确。它只能在生成器覆盖的输入分布、状态模型和运行环境中发现反例。模糊测试也不能因为运行时间长、覆盖率高或输入数量多,就被当成形式化验证。
它们最适合解决的是另一类问题:
- 手工样例无法覆盖大量组合;
- 期望输出难以逐个构造;
- 输入结构复杂且边界丰富;
- 状态顺序比单个参数更容易触发缺陷;
- 失败输入需要自动简化;
- 已知缺陷需要从探索结果转成稳定回归。
生成器决定“看什么”,不变量决定“什么算错”,缩减决定“如何理解错在哪里”,回归决定“修复后是否再次出错”。四者缺一时,随机测试很容易退化成大量不可复现的噪声;四者结合,测试才形成从探索、定位到长期防退化的完整闭环。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java JMH 基准测试:预热、黑洞、分叉、死代码和结果解释
- 下一篇:Java 数据库连接池:HikariCP、容量、超时、泄漏和监控
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论