一个往返性质怎样发现最小反例

整数编码器把整数变成十进制字符串,解码器把合法字符串还原成整数。它们的关系可以写成 decode(encode(n)) == Some(n)。普通例子可以检查零、负一和上下界;性质测试则针对生成的多个输入重复检查这条关系。

本章使用 MUnit 1.0.4 与 ScalaCheck 1.18.1,运行在 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11 上。依赖仅放入独立模块,整套章节的默认标准库构建不需要引入这些测试库。

正常实现通过两个 MUnit 用例与三百个固定 seed 的性质检查。故障实现故意拒绝所有非负数,性质测试先得到输入十二,随后缩减四次得到零。用相同配置重放,反例结构一致;把零写成独立 MUnit 用例,测试进程实际非零退出。

这条证据链包含正常结果、发现错误、缩小错误、重现错误和固定回归。测试全绿只是其中一种观察;预期失败如果没有真正发生,也不能算完成了验证。

先把业务关系写清楚

本章正常代码非常小:

1
2
def encode(n: Int): String = n.toString
def decode(s: String): Option[Int] = s.toIntOption

整数类型限制了可编码范围,字符串输入则可能非法或超出范围。因此解码返回 Option[Int],不能假设每个字符串都有整数结果。往返性质只从合法整数出发,不要求任意字符串经过解码再编码后保留原样。

例如字符串可能包含前导零或不同书写形式,规范编码后未必保持原文本。若业务要求字节级往返,性质就需要另一种定义;若只要求数值一致,比较原字符串反而会误报。选择性质之前先明确等价关系,比直接套一个“往返测试”模板更重要。

本章保留了整数最小值与最大值的常例。随机检查可能覆盖边界,但不应把关键边界是否出现完全交给随机过程。正常值、无效文本和超范围文本分别对应不同契约,测试名字也按这些条件区分。

编码与解码如果共享同一个错误假设,往返仍可能通过。例如二者都错误地把某个值转换成内部编号,再互相还原,关系可能成立但外部协议不正确。因此关系性质需要与已知规范样例配合,不能把往返通过当成格式全面正确。

MUnit让具体例子的失败可定位

普通测试使用 munit.FunSuite 与 assertEquals。一个用例遍历整数边界,比较解码编码结果与 Some(n);另一个用例检查非法文本和超出整数上界的文本均为 None。

测试输出实际列出两个用例名及通过标记。脚本不仅检查退出零,还匹配这两个名字,避免测试发现配置错误导致“没有运行任何测试但命令成功”。对于构建工具实验,测试发现本身也需要观察。

失败用例位于独立 failing/ 目录,不与正常测试一起运行。它断言故障实现对零返回 Some(0),实际得到 None。MUnit 输出比较差异和源行,进程非零;脚本把这个非零视为预期结果。

如果把失败测试永久混进普通通过套件,整章集成运行就无法区分故障演示与正常回归。隔离目录和独立命令让两者都可复跑,又不会让任何一方被忽略。实验脚本明确为每个场景设置期望退出状态。

测试框架的报告文本随版本可能改变。本次初版脚本误用了另一种报告格式,虽然两个测试已运行通过,外层验证仍失败。修正后的脚本匹配实际用例标记,并保留旧日志。这说明测试程序和验收脚本都可能有缺陷,不能把包装脚本失败直接归咎于业务实现。

生成器决定性质检查覆盖什么输入

ScalaCheck 的性质对生成器产生的输入执行。正常往返检查采用整数生成,配置最少三百次成功与一个 worker,并固定初始 seed 为 20261002。日志记录 Passed,300,对应当前配置下的三百次检查。

这不是枚举所有整数。即使输入类型有限,三百个样本与整个整数空间相比仍然很小。通过的含义是没有在本次样本与检查规则中发现反例,不能称为形式证明,也不能保证所有未来版本都正确。

生成器分布直接影响发现缺陷的机会。如果业务只在超大数量时失败,而生成器几乎只产生小正数,提高少量样本次数未必有效。应针对领域范围和关键边界设计生成策略,必要时组合普通样本与定向样本。

条件过滤也会影响有效检查量。大量生成值被前置条件丢弃时,测试看似运行很久,却可能只检验少数有效输入。应该观察成功与丢弃数量,或者直接生成满足约束的数据,避免把生成成本和有效覆盖混淆。

本章日志中正常性质没有丢弃样本。输入域简单,性质对任意整数都定义,因此不需要在条件中排除零或负数。若为了让故障实现通过而把这些值过滤掉,就改变了待验证契约,而不是修复实现。

故障实现必须能被测试区分

故障版本故意写成:

1
2
def broken(n: Int): Option[Int] =
if n >= 0 then None else decode(encode(n))

性质仍要求每个整数都能往返。反例生成器选择一到一百,因此第一次生成的正数就应该使性质失败。固定 seed 下实际原始输入是十二。这种故障容易解释,也能清楚验证测试是否真的调用了目标实现。

若测试误调用正常解码器,性质会通过,脚本中 assert(!bad.passed) 就会失败。若测试没有运行,后续反例提取也无法成功。测试设计要能排除这些误接线,而不能只看报告里出现了“property”这个词。

故障注入不代表真实项目应保留一套生产错误实现。这里的函数属于独立实验,用于证明检测链具备区分能力。实际修复可以通过临时变异、回归前失败记录或受控故障开关取得类似证据。

一个总返回常量的实现也许会被往返性质轻易识别,但更隐蔽的错误可能需要其他关系。例如序列编码丢失顺序,若测试只比较元素集合就会漏掉;金额计算舍入错误,若仅比较非负性也可能通过。性质的强度取决于它是否表达了真正需要保持的规则。

因此先列出容易出现的错误实现,再检查现有性质能否区分,是一种实用审查方式。它不要求穷举所有错误,却能及时发现“测试只重复实现”或“断言太弱”的问题。

缩减寻找更简单的失败输入

发现十二使性质失败后,ScalaCheck 尝试更简单的整数候选。最终报告零,缩减次数四。零同样违反故障实现的行为,因此可以用更少信息解释问题:非负数分支错误地返回了 None。

缩减结果不是数学意义上的全局最小证明。它是当前 shrinker、失败谓词与搜索过程得到的较小反例。对复杂数据结构,可能还有别的更小失败样例没有被搜索到;报告应写实际结果,不应宣称穷尽最小化。

本例还有一个很容易忽略的细节:初始生成器范围是一到一百,但缩减结果是零。默认整数缩减并不保证候选仍留在生成器的区间内。生成与缩减是两个相关但不同的过程。

这里零属于真正的领域整数输入,所以它是有效反例。如果性质只对正整数有定义,则需要让性质前提或自定义缩减策略与这个领域一致。否则缩减可能得到一个违反输入前提的样例,错误地把域外行为当作缺陷。

对领域对象可以让构造函数保持不变量,或设计保留约束的 shrinker。需要注意构造失败和大量丢弃可能降低缩减效果。目标是让反例更容易理解,同时仍然属于被验证的业务范围,而不是单纯追求更小的打印结果。

重放应比较有意义的数据

本章用相同 seed、worker 数、样本配置、生成器与库版本再次运行故障性质,然后比较原始参数、缩减后参数和缩减次数。三者一致,说明本次反例在相同配置下可以重现。

初版实验比较整个 status 的字符串表示,结果失败。打印表示可能包含不适合作为稳定契约的内部对象信息,不能代替结构化反例数据。最终代码从 Test.Failed 提取参数字段比较,旧失败日志仍保留,便于说明验证脚本的修复。

固定 seed 也不是脱离环境的永久重放令牌。更换 ScalaCheck 版本、生成器结构、测试顺序、worker 数或被测逻辑,都可能改变随机流消费和缩减路径。因此证据同时保存版本、代码与参数,不只保留一个数字。

对于偶发并发故障,固定随机输入也未必固定线程调度。上一章使用门闩和屏障控制交错,正是为了获得比随机 seed 更明确的时序证据。性质测试适合扩大输入覆盖,但不自动使所有外部非确定性可重放。

把反例转成普通测试可以降低长期回归对生成过程的依赖。即使以后生成器改变,broken(0) 的具体回归仍能直接执行。随机探索负责发现更多样例,固定回归负责保留已经知道的重要失败,二者相互补充。

将最小反例转成真实失败回归

独立 MUnit 反例只包含零输入与期望 Some(0)。运行时报告 Obtained None,差异显示缺少期望值,脚本确认非零退出。这个场景让失败从性质框架内部状态变成测试进程层面的二元结果。

正文中的“测试通过”必须说明通过的是什么。正常套件通过表示正确实现符合所列例子;故障演示的外层脚本通过,表示错误实现被内部测试拒绝。不能把二者混成故障实现也通过了业务测试。

修复真实业务错误后,应保留这个用例并让它转为成功,再运行相关性质与常例。本文为展示检测能力保留了故障实现,因此失败目录依然预期失败;它不是待合入生产的缺陷补丁。

若反例涉及外部服务,应把必要输入和响应保存为有限夹具,避免回归依赖远程状态再次恰好出现。夹具只能证明保存的情形,真正集成行为还要有相应测试。保留边界使回归既稳定又不夸大覆盖。

性质应围绕不变量而不是实现步骤

编码往返是关系性质,除此之外还可以考虑状态转换中的守恒或限制。例如订单数量更新不得变成非正数,拒绝命令之后状态保持不变,合法添加再删除能够恢复原状态。这些关系来自业务规则,而不是某个循环如何实现。

但不能无条件假设操作可交换。若订单命令有顺序语义,交换添加与删除可能改变结果;写一个错误的交换律性质,只会迫使实现满足不存在的要求。测试自身也是规格的一部分,需要审查。

对于集合变换,长度保持、顺序保持和元素关系是不同性质。只检查长度无法发现错误排序,只检查排序后相等又可能漏掉顺序丢失。可以用多个相互独立的性质覆盖不同维度,避免一个过于复杂的断言难以解释失败原因。

性质还应有独立判定依据。如果测试用与实现完全相同的算法重新计算期望值,同一个错误可能在两处复制。小输入下的朴素参考模型、已知样例和代数关系往往更有区分力,但参考模型同样需要验证。

本章选择标准整数文本转换,是为了让契约足够小,重点观察生成、缩减和重放链路。它没有完成一个通用编码库的测试体系,也没有把三百次成功推广到任意序列化格式。

分层测试如何帮助重构

纯函数测试可以直接比较输入输出,速度快、失败原因集中。资源和异步边界则需要验证关闭、线程终止或外部错误翻译,不能仅靠纯函数性质替代。第 33 篇的生命周期断言与本章往返性质覆盖的是不同风险。

模块测试还要确认生产代码与测试代码的可见范围,防止测试夹具意外进入发布 JAR。下一篇会通过真实多模块构建检查这些边界。测试能访问一个类,不代表库消费者也能或应该访问它。

端到端测试适合验证输入、输出与退出码的一致性,但它通常不方便生成成千上万个细粒度输入。把核心规则抽成纯函数,再用少量真实 CLI 场景验证连接方式,可以同时获得定位能力与接入证据。

重构完成后应先跑覆盖受影响行为的测试,再扩展到必要集成检查。对本章来说,改变编码函数需要常例、性质、缩减演示和固定回归;修改文案不需要反复执行整个下载流程。验证范围应对应变化,而不是用运行次数替代证据质量。

防止性质因前提过弱而空通过

一个性质可以语法正确,却几乎没有检验内容。例如先过滤掉所有可能触发错误的值,再要求剩余输入往返,就只能证明被筛选后的子集。尤其当过滤条件直接依赖被测函数是否成功时,容易把失败输入在断言之前丢掉。

可审查的前提应来自领域定义,而不是来自“哪些输入目前能让实现通过”。若订单数量要求一到一百,可以直接生成这个范围,并另写越界拒绝测试;不能把任意数量传给实现,再只对返回成功的那些情况断言成功。

空集合也可能使某些性质自然成立。检查“每个元素都符合条件”时,空集合没有反例元素,所以结果为真。这可能符合数学定义,却不足以覆盖非空数据路径。生成器应明确空与非空的比例,关键非空场景可以单列常例。

本章的故障性质从正整数生成,确保第一次有效样例进入错误分支;正常性质则保留完整整数域。两者的配置目的不同:前者验证检测链,后者探索正确实现的输入范围。不能把专门用于故障注入的窄生成器称为完整业务覆盖。

记录能复核的证据而不是漂亮总数

三百次检查只是运行参数的一部分,还应知道它们针对哪个实现、采用哪个生成器、是否发生丢弃、是否真的执行了目标测试。当前证据把源码路径、依赖版本、完整命令与输出放在一起,避免只有成功次数却无法重现。

性质失败之后,原始参数与缩减参数都值得保留。原始参数说明生成阶段如何触发错误,缩减参数提供较短解释;只保留最小样例可能掩盖生成分布问题,只保留原始样例则增加理解成本。

重放检查也应尽量比较结构化数据。本章最终比较参数值、原始值和缩减次数,而不比较含耗时的整个结果字符串。耗时变化通常不改变反例语义,把它加入相等条件只会制造无关失败。

这组记录仍不是形式化证明档案,也没有包含所有随机轨迹。它足以重跑当前实验并核验关键观察。若后续升级框架,应该产生新目录并比较结果,不覆盖旧证据后声称原运行自然适用于新版本。

复跑与练习

本章为 LAB_VERIFIED:

1
2
python3 examples/scala-lab/run.py chapter 34 --run-id local-ch34
python3 examples/scala-lab/modules/34/run.py --run-id local-ch34-module

标准库入口记录在 examples/scala-lab/evidence/20261002-ch34/。最终完整框架证据在 20261002-ch34-module-r3/,munit 记录两个成功用例,properties 记录 seed、三百次成功、原始十二、缩减零和四次缩减,minimal-regression-fails 记录真实 MUnit 失败。每项都有命令与退出码 JSON。

手算题:生成器只生成一到一百,报告零是否必定说明框架错误?不是,shrinker 可以产生生成器初始区间之外的候选。本章真实领域包含零,所以反例有效;领域若排除零,需要调整性质和缩减约束。

第二题:相同 seed 下三百次通过,是否证明所有整数都正确?不能,它只记录有限样本未发现错误。关键边界常例和其他关系性质仍有独立价值。

执行练习把故障改成只拒绝整数最小值,先观察当前随机配置是否一定发现,再增加明确边界回归。随后为一个正整数领域定义保持正数的缩减策略,比较最小反例与默认整数缩减的区别。记录 seed、版本和原始样例,不要只保存一行“随机测试通过”。

参考

MUnit 入门说明真实测试发现与运行方式;MUnit断言解释比较失败信息;ScalaCheck官方文档入口提供性质测试与反例模型。具体版本、缩减轨迹与重放结果以本章原始日志为准。前篇是同步与异步资源管理,下一篇讨论这些测试在模块与发布边界中如何组织。

顺序导航:系列入口:00 · 上一篇:33 · 下一篇:35。