Java 基础体系 · 第 83/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 属性测试与模糊测试:生成器、不变量、缩减和回归

属性测试(Property-based Testing)不是为若干个手工样例编写若干个断言,而是先描述一条对大量输入都应成立的性质,再由测试系统自动生成输入、执行性质、报告失败样例,并尽量把失败样例缩减为更容易理解的最小反例。

模糊测试(Fuzzing)则是更大的测试技术集合:它持续向程序输入大量自动生成、变异或覆盖引导的数据,重点发现崩溃、异常退出、超时、资源耗尽、解析错误和安全边界问题。属性测试通常关注“结果是否满足性质”,模糊测试既可以关注性质,也可以只关注“程序是否安全地处理输入”。

本文围绕四个核心机制展开:

  1. 生成器(generator):如何构造输入,以及如何保证输入仍然属于有效测试域。
  2. 不变量(invariant):如何把需求写成对任意输入或任意状态都成立的条件。
  3. 缩减(shrinking):失败后如何从复杂输入逐步得到更小、更清晰的反例。
  4. 回归(regression):如何保存失败证据,使修复后的行为不会再次退化。

这些机制可以在 Java 25 LTS 项目中使用。Java SE 本身没有提供属性测试或模糊测试框架;java.util.random、集合、字符串、记录类等标准能力可以用来构造测试基础设施,而完整的属性测试和模糊测试通常依赖外部测试框架。


一、先区分示例测试、属性测试和模糊测试

1. 示例测试验证有限个具体事实

传统单元测试通常直接给出输入和期望输出:

assertEquals("[1, 2, 3]", normalize("[ 1, 2, 3 ]"));

它验证的是一个具体事实:

normalize("[1,2,3]")="[1,2,3]"normalize("[ 1, 2, 3 ]") = "[1, 2, 3]"

这种测试适合表达业务规则明确、样例具有代表性的场景。但它只能覆盖被写出来的输入。即使实现对正常样例都正确,也可能在以下输入上失败:

  • 空输入;
  • 只有一个元素;
  • 负数;
  • 重复值;
  • 极大长度;
  • 非 ASCII 字符;
  • null
  • 溢出边界;
  • 不同状态顺序。

2. 属性测试验证输入集合上的普遍性质

属性测试不一定知道每个输入的精确期望值,而是验证某个性质对所有合法输入成立:

xD,P(x)\forall x \in D,\quad P(x)

其中:

  • DD 是测试输入域;
  • xx 是生成器生成的一个输入;
  • P(x)P(x) 是被测程序和断言共同定义的性质。

例如,对排序函数 sort,可以写出:

sorted(sort(x))sorted(sort(x))

和:

permutation(sort(x),x)permutation(sort(x), x)

第一条表示输出有序,第二条表示输出与原输入包含相同的元素,只是顺序可能发生变化。

这两条性质比“输入 [3, 1, 2] 应输出 [1, 2, 3]”更一般。它们不要求为每一种输入手工编写期望结果。

3. 模糊测试强调输入探索和故障发现

模糊测试的典型目标是:

run(x) 不应崩溃、挂死或违反安全约束run(x) \text{ 不应崩溃、挂死或违反安全约束}

它经常测试:

  • JSON、XML、压缩包、图片、协议帧等解析器;
  • 编码和解码器;
  • 网络协议实现;
  • 文件格式处理逻辑;
  • 认证、权限和输入校验代码;
  • 对异常输入敏感的 native 或 JVM 互操作代码。

因此,模糊测试可以有性质:

对于所有生成的字节序列,解析器不得导致进程崩溃。

也可以没有完整的业务 oracle,只检查:

  • 是否抛出允许的输入异常;
  • 是否在时间限制内结束;
  • 是否不产生非法内存访问;
  • 是否不超过内存、文件描述符或线程资源限制。

属性测试更强调“关系和不变量”,模糊测试更强调“广泛探索输入空间和异常路径”。两者可以重叠,但不是同义词。


二、生成器:测试质量首先取决于输入域

1. 生成器不仅是随机数函数

生成器是一个从随机源到测试输入的函数:

G:RDG : R \rightarrow D

其中:

  • RR 是随机状态或随机字节流;
  • DD 是测试输入域;
  • G(r)G(r) 返回一个输入。

简单的整数生成器可以是:

int value = random.nextInt();

但真实系统通常需要结构化输入。例如,测试一个订单解析器时,输入可能必须满足:

订单
 ├── 订单号
 ├── 用户
 ├── 创建时间
 └── 商品列表
      ├── 商品编号
      ├── 数量
      └── 单价

如果完全随机生成字节,大多数输入可能在最外层就无法解析,测试只能反复验证“输入格式非法”。这对健壮性测试有价值,但无法深入业务逻辑。

更有效的生成器通常分层:

  1. 生成合法结构;
  2. 在合法结构上改变边界值;
  3. 有意注入局部非法字段;
  4. 控制规模、深度和嵌套层级;
  5. 记录生成参数,便于回放。

2. 生成器必须明确“有效域”

设一个方法:

Money add(Money left, Money right)

如果 Money 的金额必须满足:

scale <= 2
currency != null
amount 不超过允许范围

那么生成器不能只生成任意 BigDecimal。否则测试失败可能来自测试数据违反构造约束,而不是被测逻辑错误。

可以把生成器看成带有前置条件的函数:

G(r)DvalidG(r) \in D_{valid}

测试系统还可以单独生成无效输入:

Ginvalid(r)DDvalidG_{invalid}(r) \in D \setminus D_{valid}

两类生成器的断言不同:

  • 对合法输入,通常验证业务后置条件;
  • 对非法输入,通常验证明确的拒绝方式、异常类型和资源边界。

常见误区是把“无法解析”都当成无价值样例。对于语法解析器,非法输入本身就是重要测试域;但如果目标是测试解析后的业务语义,就必须提高合法结构输入的比例。

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. 生成器必须控制复杂度

递归数据结构的生成器有一个实际问题:如果每个节点都随机生成多个子节点,树的规模可能指数增长。

假设每个节点平均产生 bb 个子节点,递归深度为 dd,节点数量近似为:

1+b+b2++bd1 + b + b^2 + \cdots + b^d

b>1b > 1 时,深度稍微增加就可能产生非常大的输入。结果可能是:

  • 测试时间暴涨;
  • 缩减过程极慢;
  • 堆内存耗尽;
  • 失败样例包含数万层嵌套,难以诊断。

因此,生成器需要显式传递规模参数 sizedepth,并在递归时减少它:

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. 不变量的定义

不变量是程序执行前后都应保持成立的条件。对函数而言,常见形式是:

P(x,f(x))P(x, f(x))

对状态机而言,常见形式是:

I(s)I(s)

其中 ss 是某个时刻的系统状态,I 表示状态不变量。

例如,队列的基本不变量可以是:

size >= 0
size == numberOfStoredElements
poll 后返回的元素是此前最早加入且尚未移除的元素

属性测试会生成多条操作序列,而不是只生成一个输入:

offer(A), offer(B), poll(), offer(C), poll()

每执行一步,都可以检查当前状态是否仍满足不变量。

2. 代数性质通常比固定答案更稳定

对于函数 ff,可以验证代数关系。

幂等性

若重复应用一次与应用两次相同:

f(f(x))=f(x)f(f(x)) = f(x)

典型例子:

  • 标准化;
  • 去重;
  • 格式化;
  • 配置合并后的规范化。

往返性质

编码和解码通常应满足:

decode(encode(x))=xdecode(encode(x)) = x

但这个公式只有在编码是无损的情况下成立。如果编码会丢失信息,就需要改写为:

normalize(decode(encode(x)))=normalize(x)normalize(decode(encode(x))) = normalize(x)

例如,某格式会忽略字段顺序,那么直接比较原对象可能错误,应该比较规范化后的对象。

交换律

某些运算应满足:

f(a,b)=f(b,a)f(a,b) = f(b,a)

加法集合并可能满足交换律,但字符串拼接不满足。把不适用的数学性质套到业务代码上,会产生错误的测试,而不是更强的测试。

单调性

对于排序或过滤函数,可以验证:

xyf(x)f(y)x \leq y \Rightarrow f(x) \leq f(y)

但必须先定义排序关系。字符串的字典序、自然数序、忽略大小写的序并不相同。

3. 关系式属性可以替代难以构造的 oracle

当精确答案难以独立实现时,可以比较两个实现或两个输入之间的关系。

例如,数据库查询优化前后的结果应该相同:

queryoriginal(d)=queryoptimized(d)query_{original}(d) = query_{optimized}(d)

同一个压缩算法在不同分块方式下解压后应相同:

decompress(compress(x))=xdecompress(compress(x)) = x

对输入做等价变换后,结果应保持某种关系:

f(transform(x))=transform(f(x))f(transform(x)) = transform'(f(x))

这类属性称为差分属性或蜕变属性(metamorphic property)。它们尤其适用于:

  • 编译器;
  • 查询优化器;
  • 序列化框架;
  • 加密和压缩实现;
  • 无法轻易写出完整期望结果的复杂算法。

4. 浮点数、异常和副作用会改变属性定义

不要直接对浮点结果使用精确相等:

assertEquals(expected, actual);

对于浮点计算,通常应定义误差:

actualexpectedϵ|actual - expected| \leq \epsilon

但这对 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]

这里的测试过程不是“随机调用一个方法”这么简单,而是由几个步骤组成:

  1. generateArray 生成输入;
  2. propertyHolds 执行两个独立性质;
  3. 第一个失败输入被记录;
  4. shrink 删除元素或把数值向零缩减;
  5. 只有候选输入仍然失败时,才接受该缩减;
  6. 最终输出可以稳定重放的反例。

将实现修正为:

private static int[] correctSort(int[] input) {
    int[] result = input.clone();
    Arrays.sort(result);
    return result;
}

并让 propertyHolds 调用 correctSort,所有生成样例应通过。

这个示例中的第二个性质也很重要。如果只检查“输出有序”,一个实现把所有元素直接替换成空数组,也可能在某些测试中被错误地判定为通过。排序的正确性至少需要同时约束顺序和元素保留关系。


五、缩减:从复杂故障得到最小反例

1. 缩减不是简单截断

当一个模糊输入包含数千字节、数百个对象或很深的嵌套结构时,直接把输入截断并不一定仍然触发故障。

缩减器需要寻找一个更小的输入 xx',满足:

size(x)<size(x)size(x') < size(x)

并且:

failure(x)=truefailure(x') = true

因此,缩减本质上是一个受约束的搜索问题:

minxDsize(x)s.t.failure(x)\min_{x' \in D} size(x') \quad \text{s.t.} \quad failure(x')

这里的 size 可以是:

  • 字节数;
  • 集合元素数量;
  • 树节点数;
  • 字符串长度;
  • 操作序列长度;
  • 数值绝对值;
  • 嵌套深度。

2. 缩减必须保持输入域

假设测试的是合法 JSON 文档,缩减后不能随意删除任意字节,否则结果可能只是语法错误,而不是原始业务故障。

更可靠的做法是按语法树缩减:

对象
 ├── 删除一个字段
 ├── 缩减字段值
 └── 缩减嵌套对象

数组
 ├── 删除一个元素
 └── 缩减某个元素

对命令序列也应按命令边界缩减:

PUT a=1
PUT b=2
DELETE a
GET b

不能只删除任意字符,而应尝试:

  1. 删除一条命令;
  2. 删除连续的一组命令;
  3. 缩减命令参数;
  4. 保持前置条件仍成立;
  5. 检查故障是否仍存在。

3. 贪心缩减、分块缩减和最小性

前面的示例使用贪心算法:依次尝试候选输入,只要仍然失败就立即接受。

这种算法简单且通常足够实用,但它不保证得到全局最小解。原因是局部选择可能阻止另一条更短路径。例如:

先删除元素 A -> 仍失败
先删除元素 B -> 仍失败
删除 A 后再删除 B -> 不失败
删除 B 后再删除 A -> 仍失败

不同候选顺序可能得到不同结果。

工程中的缩减器常用以下策略组合:

  • 优先删除大块结构;
  • 再删除单个元素;
  • 再缩小数值;
  • 再缩短字符串;
  • 最后处理字节级修改;
  • 对每个候选重新执行故障判定。

缩减器的判定函数必须尽可能稳定。若故障只以 1% 的概率出现,缩减器很难判断候选输入是否保留了故障。此时应先提高重现性,再谈缩减。

4. 缩减器也可能改变故障类型

一个输入可能同时导致:

  • 超时;
  • 内存压力;
  • 断言失败;
  • 错误异常;
  • 进程崩溃。

如果缩减器只判断“程序退出码非零”,它可能把原本的业务错误缩减成另一个无关异常。故障判定应区分类型,例如:

目标故障:抛出 IllegalStateException
非目标故障:OutOfMemoryError
非目标结果:合法返回

模糊测试中的“仍然失败”不是一个足够精确的定义。必须说明保留哪一种失败。


六、状态机属性:测试操作序列而不是单个输入

许多工程缺陷只有在操作顺序变化时才出现,例如:

  • 缓存先失效再读取;
  • 事务提交后重复提交;
  • 连接关闭后再次发送请求;
  • 队列先出队再入队;
  • 先取消任务再等待结果。

可以把系统抽象为状态机:

si+1=step(si,commandi)s_{i+1} = step(s_i, command_i)

其中:

  • sis_i 是第 ii 步之前的系统状态;
  • command_i 是生成的一条操作;
  • step 执行操作并产生新状态;
  • si+1s_{i+1} 是新状态。

测试同时维护一个较简单的模型状态 mim_i

mi+1=modelStep(mi,commandi)m_{i+1} = modelStep(m_i, command_i)

然后检查系统状态与模型状态之间的关系:

relation(si,mi)relation(s_i, m_i)

例如,测试一个集合实现,可以用 HashSet 作为参考模型:

命令:add(x)
系统:被测集合 add(x)
模型:参考 Set add(x)
断言:两者返回值相同,size 相同,contains(x) 相同

状态测试的关键步骤

  1. 生成当前状态允许的命令;
  2. 执行被测对象;
  3. 执行参考模型;
  4. 比较返回值和可观察状态;
  5. 保存命令序列;
  6. 失败后缩减命令序列;
  7. 重放缩减后的序列。

命令生成必须考虑前置条件。例如,空队列上的 poll 可能是合法操作,也可能不允许。如果生成器不区分前置条件,测试的大部分时间可能都在验证“操作被拒绝”。

资源状态也是状态的一部分

数据库连接、线程池、文件句柄、事务和临时目录都属于状态。只检查返回值可能漏掉资源不变量:

操作前连接数 = 10
操作后连接数仍应为 10

对于失败路径尤其重要:

请求超时后,事务必须回滚;
解析失败后,临时文件必须删除;
取消任务后,不应继续提交结果。

这些属性往往比“正常路径返回成功”更能发现生产问题。


七、并发属性需要明确观察范围

并发测试不能简单地把顺序测试循环执行很多次。并发缺陷依赖:

  • 线程交错;
  • 内存可见性;
  • 锁的持有范围;
  • 中断和取消;
  • 超时;
  • 调度器行为;
  • 共享资源生命周期。

对并发组件,常见的不变量包括:

计数器不会丢失更新;
同一个任务不会被执行两次;
关闭后的资源不会接受新操作;
所有提交的任务最终都有明确结果;
失败不会遗留锁或占用连接。

但“执行一万次没有复现”不是证明。并发测试只能增加某些调度交错被观察到的概率。

如果要验证线性化性质,需要记录每个操作的调用时间和返回时间,并寻找一个满足实时先后关系的顺序。若操作 AB 开始前已经返回,则线性化顺序必须满足:

A<BA < B

这类测试比检查最终计数值更强,因为某些错误实现可能偶然得到正确最终值,却在中间状态违反线程安全契约。

Java 内存模型由 Java 语言规范和相关平台规范定义,但属性测试框架不会自动替你证明可见性和原子性。需要使用正确的并发原语,并针对可观察的并发契约设计测试。


八、随机性、种子与可重现性

属性测试的随机性必须可控。一个失败报告至少应该包含:

测试名称
随机种子
生成次数
失败输入
运行参数
JDK 版本
操作系统和架构
被测代码版本

种子可以让生成序列重新出现,但有几个限制:

  1. 生成器算法改变后,同一种子不一定得到同样输入;
  2. Java、框架或随机算法实现改变后,序列可能变化;
  3. 并发测试中的线程调度不能仅靠种子完全复现;
  4. 时间、时区、默认字符集和外部服务也会影响结果;
  5. 随机输入若没有序列化保存,未来可能难以精确重放。

因此,种子用于定位和初步回放,最小失败输入用于长期回归。两者不能互相替代。

不要在测试过程中使用不可控的全局随机状态:

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. 字节级模糊和结构化属性应组合使用

测试网络协议时,可以分两层:

  1. 生成结构合法的协议帧,深入测试业务处理;
  2. 对长度、校验和、截断和随机字节进行变异,测试拒绝路径和健壮性。

只做第一层可能漏掉恶意输入;只做第二层则大量停留在协议头解析阶段。


十一、解析器模糊测试中的故障分类

对于一个输入解析器,结果不能只分为“成功”和“失败”。至少应区分:

结果 通常含义 是否一定是缺陷
返回合法对象 输入被接受 需要继续检查对象不变量
抛出预期输入异常 输入被拒绝 通常不是缺陷
抛出未声明运行时异常 可能是实现缺陷 需要诊断
超时 可能存在算法复杂度或死循环问题 通常是缺陷
进程崩溃 严重健壮性或 native 问题 是缺陷
内存耗尽 可能是资源攻击面 需要结合契约判断

例如,一个 JSON 解析器拒绝随机二进制通常是正常的;但如果某个输入让它出现 StackOverflowError,这可能说明嵌套深度没有限制。

对于资源边界,还要记录:

输入长度
嵌套深度
解析耗时
堆内存变化
创建对象数量
线程数

否则很难判断是正常的大输入,还是拒绝服务风险。


十二、属性测试中的常见误区

1. 生成了大量输入,却没有扩大有效覆盖

如果生成器产生的 99% 输入都在第一行被拒绝,测试次数增加并不能等价于测试深度增加。

诊断方法包括:

  • 统计每个解析阶段的输入数量;
  • 统计异常类型分布;
  • 统计关键分支命中次数;
  • 查看输入规模和结构分布;
  • 检查是否总是生成相同的边界形态。

2. 属性本身重复实现了被测代码

如果排序实现使用一个算法,测试 oracle 又使用几乎相同的算法,两个实现可能共享同一个错误。

更好的组合是:

  • 代数性质;
  • 独立的简单参考实现;
  • 固定边界样例;
  • 差分实现;
  • 状态模型。

参考模型不一定性能高,但应尽量简单、独立、容易审查。

3. 只检查最终状态,遗漏中间状态

错误的队列实现可能最终包含正确元素,但某次 poll 返回了错误元素。状态机测试应在每个命令后检查:

返回值
异常
size
contains
迭代顺序
资源状态

最终状态断言不能替代逐步不变量。

4. 缩减破坏了原始前置条件

如果故障需要先登录再访问资源,缩减器删除“登录”命令后,最终可能只是得到一个未认证错误。此时输入虽然短了,但不再表示原始故障。

状态序列缩减必须同时验证:

precondition(commandi,statei)=trueprecondition(command_i, state_i) = true

只有保持命令前置条件,并且仍然触发同类故障的候选,才是有效缩减结果。

5. 把偶发环境故障当作确定性属性失败

网络服务、时间、线程调度和外部数据库会让同一个输入产生不同结果。对于这类测试,应先隔离环境:

  • 使用内存或容器化依赖;
  • 固定时钟;
  • 控制超时;
  • 使用虚拟网络;
  • 记录外部响应;
  • 明确重试策略。

否则缩减器和回归测试会在外部故障与代码缺陷之间反复摇摆。


十三、在 Java 25 项目中组织属性测试

Java 25 只规定语言和标准库行为,不规定测试框架的生成器、缩减器或覆盖反馈 API。项目可以按以下边界组织代码:

src/test/java/
 ├── unit/              示例测试和小规模边界测试
 ├── property/          生成器、不变量、状态模型
 ├── fuzz/              模糊目标和输入适配器
 └── regression/        已缩减失败样例

生成器和模型应尽量是纯函数。纯函数具有几个好处:

  • 随机种子更容易重放;
  • 缩减时副作用更少;
  • 失败判定更稳定;
  • 可以在不启动外部服务的情况下执行。

涉及数据库、文件和网络时,测试输入仍应与环境解耦。例如,把“生成订单”和“提交订单”分开:

生成订单对象
    ↓
检查对象不变量
    ↓
序列化
    ↓
调用数据库或 HTTP 层
    ↓
检查返回值、状态和资源清理

这样可以分别定位:

  • 生成器错误;
  • 序列化错误;
  • 传输错误;
  • 持久化错误;
  • 事务和清理错误。

测试强度不能只由次数决定

测试次数 NN 增加后,某个概率为 pp 的缺陷至少被命中的概率是:

1(1p)N1 - (1-p)^N

pp 很小时,增加次数确实有帮助;但如果生成器从不产生触发结构,则 p=0p=0,无论运行多少次都不会发现缺陷。

因此,应同时改进:

  • 输入域;
  • 边界分布;
  • 结构深度;
  • 状态序列;
  • 代码覆盖;
  • 故障分类;
  • 缩减和回归流程。

十四、一条完整的属性测试与模糊测试生命周期

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[运行回归与属性测试]

关键路径是:

  1. 先定义契约:没有明确的不变量,就无法判断随机输入是否暴露错误。
  2. 再生成输入:生成器要覆盖合法、边界和有意非法的数据。
  3. 执行并分类:预期拒绝不能和崩溃、超时混为一谈。
  4. 保存证据:至少保存输入、种子和运行环境。
  5. 缩减并确认:缩减后的输入必须仍触发同类故障。
  6. 转为回归:修复后不依赖随机概率,而是确定性验证该缺陷。
  7. 继续探索:回归固定已知问题,属性测试和模糊测试继续寻找未知问题。

十五、如何判断一条属性是否真正有价值

一条属性至少应回答三个问题:

输入是什么

明确输入域:

任意整数数组
合法订单
带有前置条件的命令序列
任意 UTF-8 字节序列

如果输入域含糊,生成器和断言就会互相矛盾。

正确性是什么

明确是:

  • 精确结果;
  • 近似结果;
  • 代数关系;
  • 状态不变量;
  • 允许异常集合;
  • 时间或资源上界;
  • 不崩溃条件。

“程序不应该出问题”不能直接执行,必须转换成可观察的判断。

失败后如何复现

明确记录:

  • 输入;
  • 操作顺序;
  • 随机种子;
  • JDK 和框架版本;
  • 系统属性;
  • 时区和编码;
  • 外部依赖响应;
  • 故障分类。

只有能够可靠复现和缩减,属性测试发现的失败才会从一次日志事件变成可修复的工程证据。


十六、最终边界

属性测试不能证明实现对无限输入域全部正确。它只能在生成器覆盖的输入分布、状态模型和运行环境中发现反例。模糊测试也不能因为运行时间长、覆盖率高或输入数量多,就被当成形式化验证。

它们最适合解决的是另一类问题:

  • 手工样例无法覆盖大量组合;
  • 期望输出难以逐个构造;
  • 输入结构复杂且边界丰富;
  • 状态顺序比单个参数更容易触发缺陷;
  • 失败输入需要自动简化;
  • 已知缺陷需要从探索结果转成稳定回归。

生成器决定“看什么”,不变量决定“什么算错”,缩减决定“如何理解错在哪里”,回归决定“修复后是否再次出错”。四者缺一时,随机测试很容易退化成大量不可复现的噪声;四者结合,测试才形成从探索、定位到长期防退化的完整闭环。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。