测试框架变化,业务契约保持相同

先修:14、23、25。核心问题:更换测试组织方式后,如何证明行为契约与反例检测仍然有效。实验入口:examples/ruby/labs/E02/run.rb。验收:两套真实协议正例通过、边界变异失败,保留一百组生成测试的种子与收缩记录。版本边界:CRuby 3.4.11、Minitest 5.25.5、RSpec 3.13.2,不声称穷尽输入空间。

同一条业务规则,可以写成 Minitest 的测试方法,也可以写成 RSpec 的 example。语法差异不应该导致两套测试验证不同的东西。Taskbook 的筛选协议提供了一个小型对照:优先级等于阈值的任务必须保留,非法阈值必须拒绝,校验后的可变字符串不能与输入共享。

实验同时运行真实 Taskbook 实现与一个故意写错的边界变异。正确实现必须被两套测试接受,错误实现必须被两套测试拒绝。这比比较断言语法更接近迁移测试框架时真正需要的证据:原有行为约束有没有丢失。

环境固定 CRuby 3.4.11、Minitest 5.25.5 与 RSpec 聚合 gem 3.13.2。RSpec 的 core、expectations、mocks 各组件可能有不同补丁版本,具体解析结果以 E02 的独立锁文件为准。RSpec 版本页确认聚合版本存在,但不能据此推断所有组件都叫三点十三点二。完整代码在 examples/ruby/labs/E02/,不依赖 Rails,也不修改主工程依赖。

把协议写成可执行的行为

contract.rb 保存三个有名称的检查,每次执行都重新取得任务数据,不依赖前一条测试的修改结果。第一条同时比较筛选值和输入快照:

1
2
3
4
5
tasks = Taskbook.sample_tasks
before = Marshal.dump(tasks)
actual = Taskbook.filter(tasks, min_priority: 4)
expected = tasks.select { |task| task[:priority] >= 4 }
actual == expected && Marshal.dump(tasks) == before

阈值取四是有意的。样本优先级为五、二、四,如果使用三,严格大于和大于等于恰好得到同样结果,测试名称虽然说“保留边界”,数据却没有触及边界。测试意图需要由输入证明,而不是由名称声明。

第二条检查非法优先级零触发 ArgumentError;只有预期异常被捕获,其他异常继续向测试框架传播。第三条修改校验结果的标题字符串,再确认输入标题不变。它验证的是外部可观察的别名关系,而不是强迫实现使用某一种复制方法。不同内部算法只要满足同一协议,都应被接受。

Marshal 在这里仅用于对实验内可信对象做快照,没有读取外部提供的序列化内容。把这种测试辅助代码搬到用户输入边界,再调用 Marshal.load,会引入完全不同的问题。领域契约测试可以依赖可信夹具,但不能因此放松真实入口的格式校验。

契约检查返回布尔值,使两种框架共用同一份行为。代价是失败信息只指出哪一条契约失败,没有结构化显示字段差异。更大项目可以共享输入与期望,把最终断言留给各框架,以保留更好的差异报告。这个实验选择较小的公共协议,目的在于确认组织方式变化没有偷偷改变测试内容。

Minitest 与 RSpec 如何承载同一契约

Minitest 通过继承测试类并定义测试方法组织执行;RSpec 使用 describe 分组和 it 声明 example。两者都实际调用同一检查,不把 Taskbook 替换为 mock:

1
2
3
4
5
class ProtocolTest < Minitest::Test
TaskbookContract.cases.each_with_index do |(description, check), index|
define_method("test_contract_#{index}") { assert check.call, description }
end
end
1
2
3
4
5
RSpec.describe 'Taskbook real protocol' do
TaskbookContract.cases.each do |description, check|
it(description) { expect(check.call).to eq(true) }
end
end

实测 Minitest 为三次运行、三个断言、零失败;RSpec 为三个 examples、零失败。这里没有把断言数量解释成覆盖率,两者只是相同输入与规则的执行记录。嵌套 context、let 的求值时机、测试替身与复杂生命周期都没有进入比较,不能由这个实验给两种框架做普遍优劣排序。

在独立子进程中设置 TASKBOOK_MUTANT=1,契约文件临时向 Taskbook 单例类 prepend 一个模块:调用真实筛选后,再删除优先级等于阈值的任务。主文件完全不修改,子进程结束后变异消失。两套框架都必须以退出码一结束,且输出中出现边界契约名称。仅看“有一条失败”不够,因为依赖加载失败也会产生非零退出码。

迁移测试工具时,先建立共同的行为样本,再注入同一个已知错误。正例证明测试能够执行,反例证明关键断言确实参与判定。只把绿色结果换一种输出格式,无法证明迁移保留了检测能力。

生成输入与随机测试顺序是两件事

手工样本适合表达重要边界,但组合空间会随任务数量、状态、标签与优先级增长。生成式测试用一个明确的生成器产生合法任务,再检查一组性质。这个实验没有增加专用生成式 gem,而是使用 Ruby 的 Random,生成一百组任务数组,每组一到八条记录,优先级与阈值均在一到五之间。

1
2
3
4
5
6
seed = Integer(ENV.fetch('SEED', '20261002'), 10)
random = Random.new(seed)
selected = Taskbook.filter(tasks, min_priority: min)
raise 'idempotence' unless Taskbook.filter(selected, min_priority: min) == selected
raise 'threshold' unless selected == tasks.select { |task| task[:priority] >= min }
raise 'input mutated' unless Marshal.dump(tasks) == before

每组检查筛选幂等、阈值结果和输入不变。幂等性质不能单独证明正确:永远返回空数组的实现同样幂等。因此还要有明确的参考结果以及前面的固定边界样本。性质之间应互相补充,不能因为一句数学形式简洁就把它当成完整规格。

生成器为 ID 使用顺序正整数,为标题与标签使用有效字符串,因此生成的对象满足核心协议。这样筛选性质失败时,原因集中在筛选逻辑,不会被无效输入的预期异常淹没。拒绝非法输入则由另一类生成器或独立契约负责;把合法与非法样本混杂,却不区分预期,会使失败解释变得含糊。

RSpec 的 --seed 控制 examples 的顺序,不会自动替业务生成器设置全局随机数种子。官方随机化说明明确区分这两者。本实验顺序种子固定为二〇二六一〇〇二,数据种子通过 SEED 环境变量传入,并写入结果文件。重新执行时除了种子,还应保留生成器代码和解释器版本;修改随机调用顺序之后,同一个整数未必产生同一批业务对象。

从七条记录缩到一条边界记录

为了检验生成器是否触及有用区域,程序同时计算故意错误的 priority > min 结果。基线种子找到一个七条任务、阈值为二的差异输入。收缩器尝试逐条删除记录,每次都重新执行“正确筛选与变异结果不同”的判定;只有差异仍在时才接受删除。

generated.json 保存原始输入、每次删除的索引和数组长度、删除后的单条记录,以及最后的规范化输入。实际删除顺序把七条降到六、五、四、三、二、一,保留的记录优先级等于二。之后程序针对这个已知边界错误,把单条记录简化为:

1
2
{ tasks: [{ id: 1, title: 'x', priority: 1, tags: [], status: :todo }],
min: 1 }

正确实现返回一条,错误实现返回零条;再删除唯一记录后,两者都返回空数组,差异消失。因此这个结果在“删除任务记录”操作下已经最小。标题、标签和阈值的规范化是针对该错误的人工规则,不是能寻找任意 Ruby 程序全局最小反例的算法。

收缩过程中必须保持输入合法。若把优先级降为零,正确实现会抛异常,原先的边界比较差异就被另一个现象替代。一个看似更短的对象,只有仍满足生成域、仍触发同一判定,才能成为有效反例。真实生成式工具通常也需要领域合适的生成器与收缩规则,不能靠随机数据替代规格设计。

重放与证据

失败输入应作为回归样本保留,而不只是保留一个随机种子。种子适合重放同一版本生成器的探索过程,具体输入则能在生成器重构后继续表达已经发现的边界。两种材料保存的对象不同:前者保存生成条件,后者保存业务反例;只保留其中一种会降低后续排查的确定性。

先在独立目录安装冻结依赖,再运行入口:

1
2
3
4
cd examples/ruby/labs/E02
BUNDLE_PATH=.bundle/vendor bundle install
BUNDLE_PATH=.bundle/vendor bundle exec ruby run.rb
SEED=20261002 BUNDLE_PATH=.bundle/vendor bundle exec ruby run.rb

主工程中的 bundle exec ruby labs/E02/run.rb 会通过子进程切换到 E02 的 Gemfile。入口清理继承的 Bundler 环境,保证 Rails 实验或主工程先前激活的依赖不会决定这里加载的测试框架版本。实验不自动联网安装依赖;缺少安装产物时应按 README 先安装。

证据目录同时保存 Minitest、RSpec 的正确输出与变异失败输出,生成式结果另存为 JSON。正常入口最终退出零,是因为它确认正例通过、负例按预期失败,不是把子进程的失败忽略。若一个变异意外存活,父进程会抛异常并失败。

练习与判断范围

练习一:将固定样本阈值从四改为三,保留边界变异,观察两套固定契约是否仍能拒绝错误。解释为什么生成式测试可能继续检测到问题,而固定样本的测试名称没有兑现。恢复四后确认负例重新失败。

练习二:加入“所有结果的 ID 都来自输入且顺序不变”的性质,再注入返回逆序结果的变异。保存新的数据种子、原始反例与收缩轨迹,说明现有的幂等性质为什么不足以单独识别顺序错误。

证据 可以支持的结论 不能支持的结论
两框架运行同一协议 本组规则没有因语法迁移丢失 所有框架特性等价
边界变异失败 断言能检测这个错误 任意错误都会被检测
一百组生成输入 当前种子覆盖了这些组合 对输入全集的证明
单条反例 记录删除意义下最小 全局最小化算法

关于生成器状态与种子,可进一步查阅 Ruby 3.4 Random 文档;RSpec 的组织方式以 RSpec Core 文档为准。测试框架负责运行与报告,契约、输入分布和反例判定仍由测试设计决定。

系列导航

导读 · 上一篇:E01:Rails 与 ActiveSupport 的语言边界 · 下一篇:E03:JRuby 与 JVM · 完整源码包