一个把结果倒序返回的 map,在空列表和单元素列表上都可能通过测试。输入只有一个订单时,倒序没有可见差异;订单增加到两条且内容不同,恒等映射就改变了业务顺序。测试需要找到能区分实现的输入,还需要把输入缩小到足以解释错误的程度。

本章用 Java 21 实现一个有限的性质测试实验:生成数值与函数参数,检查组合关系,主动制造错误,再保存随机种子与缩减样本。它不替代完整测试框架,也不把一千次成功称为数学证明。重点是让每一项“通过”都能回答输入来自哪里、比较了什么、什么情况下本应失败。

前置知识是 map、flatMap、错误累积与显式状态。完整的 Main.java 包含生成器、缩减器与可失败断言;本次运行证据 保存环境、源文件摘要、命令和实际输出。

先确定观察等价

表达式两边都返回 Optional,还不足以说明它们相等。本实验比较 Optional 内部整数的值;错误列表比较元素及顺序;账户转换比较转换后的余额和失败时是否保持旧余额。对象地址、分配次数和耗时不在本次代数等价关系中。

选择观察方式会改变测试。错误集合若允许按任意顺序显示,可以采用集合等价;如果界面按字段顺序提示,就必须比较列表顺序。把所有日志排序之后再比较,可能掩盖动作次序错误。比较规则应该来自接口契约,而不是为了让某一实现通过。

函数相等更不能用 Java 对象的 equals。两个 lambda 可以对所有本次输入产生相同结果,却是不同对象。本章生成函数的参数,再在固定输入上比较应用结果。这种外延比较只覆盖实际使用的输入域,没有判断两个任意程序是否等价。

性质测试把“给出输入和预期输出”扩展成“给出输入生成方法与应满足的关系”。例子测试仍然必要,因为两个互相配合的错误实现可能满足关系。编码器和解码器同时错误地偏移一个单位,往返性质仍可能成立;外部协议中的一个已知字面样例就能揭示问题。

生成函数,而不只生成数据

实验中的函数族是整数仿射变换,系数范围为负三到三,常数范围为负五到五,输入范围为负一百到一百。这个范围使两次组合仍落在 int 安全区间内,避免把溢出语义混进初次组合检查。

1
2
3
record Affine(int a, int b) implements IntUnaryOperator {
public int applyAsInt(int x) { return a * x + b; }
}

这里产生的函数并非所有整数函数。它不包含查表、分段、异常、空返回和读取外部状态的函数。记录这个限制很重要:同一份组合测试换成任意 Java Function 后,原有推理前提可能已经不成立。所谓“随机函数”必须有可描述的构造方法,不能把几个固定 lambda 包装成覆盖整个函数空间。

每次试验生成 x、f、g,然后比较两次 map 与一次组合 map。实现还检查 Optional 的右单位元。它使用非空 Optional 与无 null 的函数,关注正常值组合;缺席与 null 的互操作问题由专门边界实验处理,不能因为本章通过便删掉那些反例。

1
2
3
4
var m = Optional.of(x);
check(m.map(f::applyAsInt).map(g::applyAsInt).equals(
m.map(v -> g.applyAsInt(f.applyAsInt(v)))), "composition");
check(m.flatMap(Optional::of).equals(m), "right identity");

固定 seed 为 2026100331。同一 JDK 下,生成逻辑、调用顺序与 seed 都保持不变,才能重现同一输入序列。在循环前额外取一个随机数,后面的样本就会整体移动。因此证据同时记录源码摘要,而不是只保存一个数字。

一千次是测试预算,不是置信度百分比。输入分布、错误出现区域和试验之间的关系都没有建立概率模型,不能由次数推导“正确率达到某个数字”。关键边界最好直接枚举,再用随机样本扩展覆盖。

错误组合与状态不变量

错误累积采用列表连接。实验分别构造标识、数量、价格错误,检查 (a ++ b) ++ c 与 a ++ (b ++ c) 的完整结果。列表连接满足的是结合关系,不能把它误读为交换关系。将数量错误放到标识错误之前会改变结果顺序,即使两边含有相同三段文字。

测试中的 concat 用新列表承载结果,避免修改传入列表。若实现改为直接向 a 追加元素,单次相等有时仍会通过,但重用 a 的后续试验就会受到污染。针对这种实现应增加原输入不变的性质,并把每个样本的列表独立创建。纯度假设需要代码结构和行为检查共同支撑。

账户例子生成零到一千的余额与扣减量。扣减不超过余额时返回差值,否则返回原余额。断言包含两部分:终态不能为负;拒绝时终态等于初态。只检查非负余额会放过“拒绝时错误清零”的实现,所以一个看起来合理的性质未必足够强。

这段状态测试是单步规则验证。它没有生成命令序列,没有事务,也没有并发提交。若业务需要先充值后扣款,应生成动作列表并逐步传递状态,观察每一步的回执。随机产生一个终态再检查非负,无法替代合法转换路径的验证。

状态性质还有一个常见前提问题:金额采用固定宽度整数时,加法可能溢出。本实验只做受限非负扣减,避开这个额外维度。范围扩大时,输入生成器与被测函数都要调整;把上界从一千改成任意 int 后继续保留原说明,会让测试的契约与实现脱节。

缩减必须保留失败

故障实现 badMap 复制列表后倒序。discover 生成长度零到八、元素负二十到二十的列表,找到首个满足“倒序后不等于原列表”的输入。本次实际发现 [-16, 5, 3, -12, -2, 1, 13],随后缩减为 [0, 1]。

缩减器先尝试删除一个元素,只有候选仍然失败才接受;扫描没有进一步删除机会后,再把各整数向零减半。每一次修改都要重新运行失败谓词。否则简单地把所有整数变成零会得到 [0, 0],倒序错误不再可见,所谓最小反例反而成了成功样本。

1
2
3
4
5
6
7
var candidate = new ArrayList<>(xs);
candidate.remove(i);
if (fails(candidate)) {
xs = candidate;
changed = true;
break;
}

[0, 1] 在本缩减策略下足够解释问题:两个不同元素交换顺序,输出与输入不相等。它不是在某个已定义全序下证明得到的全局唯一最小值。改变删除顺序,可能得到另一组同样简洁的反例;改变数值缩减规则,也可能得到 [1, 0]。

最终断言不仅检查缩减样本仍失败,还要求长度为二;单元素 [0] 则必须不能揭示反转。这个负控制验证的是测试自身的区分能力。如果错误实现偷偷返回原列表,discover 必须抛出异常,而不能打印“找到反例”后继续通过。

反例应该成为稳定回归输入。随机寻找便于探索,最小样本便于解释与日常检查。工程上可以同时保留二者:正常流水线直接检查已知反例,较长周期再跑更多种子。只保存 seed 而没有生成器版本,几年后可能无法重现原始序列;只保存缩减样本则丢失了它从什么分布被发现的信息。

与既有性质测试文章的分工

Scala 测试性质与失败重放 的“生成器决定性质检查覆盖什么输入”段落,讨论整数编码往返、固定 seed 和有效样本数;其缩减过程使用 ScalaCheck。本章保留该入口,增加函数参数生成、错误列表关系与状态不变量,并用小型手写缩减器展示候选接受条件。这里没有复用或改写旧实验文件。

Cats Law testing 展示了 Eq、Arbitrary 与规则集如何进入自动检查。正式库提供了更系统的定律组织和测试框架集成;本章只是把其中需要的角色拆开,并未运行 Cats 的完整规则集。对外宣称某个自定义 Monad 通过验证时,仍应列明具体实例、规律、生成器和观察等价。

性质本身也可能写错。例如将“非交换操作应该满足交换律”当作规格,失败只能证明这个要求不符合设计。先手算最小样例,再运行生成器,可以区分错误性质与错误实现。测试工具不会替领域选择什么关系应该成立。

复跑与练习

在仓库根目录运行 node examples/functional-programming/run.mjs 31。runner 在临时目录编译 Java 21,执行可失败检查并刷新本章 result.json。正常执行要求退出码零、输出包含固定 seed 和缩减样本,以及七组检查名称;如果没有找到反例,程序本身会失败。

手算题:输入 [2, 2] 为什么不能暴露反转错误?输入 [2, 3] 删除任意元素后还失败吗?答案分别是值序列没有变化,以及删除后只剩一个元素,错误不可见。再写出 Optional<Integer>.map(f) 的结果类型,说明 f 返回 Optional 时为何会多一层。

修改题:把坏实现改成“只删除最后一个元素”,保持空列表不变,重新设计缩减器的预期。验收必须发现非空反例、保持缩减后仍失败,并证明空列表是负控制。不能沿用“长度为二”的旧断言,因为缺陷已经改变。

另一个扩展是生成充值与扣款命令列表。把“拒绝后状态不变”施加到每一步,再故意注入扣款失败却清零的实现。记录命令序列缩减与数值缩减的先后顺序。这个扩展尚未包含在本章运行证据中,完成练习时需要产生新的实际结果。

让测试本身接受负控制

生成器有时会把真正重要的边界稀释掉。若账户余额只从一个很大的区间均匀抽取,恰好余额为零、扣减等于余额的样本可能很少。工程上可以混合边界枚举与随机生成,并分别报告数量,不能把没有出现过的边界归入随机测试的覆盖声明。

缩减也必须保持输入前提。当前列表反转允许任意整数列表,因此删除元素不会破坏合法性;若输入改成有序列表、非空树或数量与金额相互约束的订单,独立缩小每个字段可能产生非法样本。失败若变成“解析器拒绝非法输入”,就已经不是原来要解释的业务缺陷。缩减器需要保留前置条件,并重新检查同一失败关系。

本实验没有检查 Optional 的左单位元与完整结合律,也没有生成空 Optional。右单位元和 map 组合的成功不能被汇总成“全部 Monad 定律通过”。扩大验证时,应把每条关系、输入上下文和函数返回类型分开列出,尤其要检查函数可能返回缺席或抛异常的情况。

负控制还可以用于验证回归有没有失效。暂时把 badMap 改为正确实现,寻找反例必须失败;恢复错误实现,最小样本必须重新失败。这里的预期失败是在独立测试环境中确认检验能力,不能把它混入正常通过率,也不能在生产代码中长期保留故障开关。