系列导航

导读 · 上一篇:22:gemspec 与可安装的交付边界 · 下一篇:24:调试器、警告与语法诊断 · 完整源码包

测试一直通过,可能是没有观察到错误

一个统计函数应该报告任务数量。如果把实现改成永远返回零,测试仍然通过,就说明测试没有约束这个公开行为。测试文件存在、运行命令退出零、打印绿色结果,都不能替代“错误实现确实会被识别”这个问题。

第 23 篇把已有断言组织进 Minitest,并给测试入口设置一个具体的负控制:在临时子进程中把 Taskbook.summary 替换成错误版本,随后运行同一组行为测试,要求它返回失败。原工程实现保持不变,错误只存在于该子进程。

前置是异常、资源管理、包与入口。测试使用固定的 Minitest 5.25.5;项目入口是 test/engineering_boundaries_test.rb,实验调度脚本是 labs/23/run.rb。官网可能已经展示更新版本,运行证据以工程锁定版本为准。

从脚本断言过渡到独立场景

最早的实验使用 raise unless condition。这种写法依赖少,适合解释单个语言机制;场景增多后,需要知道哪一条失败、期望与实际分别是什么,并继续收集互不依赖的场景结果。

Minitest 提供测试类和断言表达这些要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
require 'minitest/autorun'
require 'taskbook/io'
require 'stringio'

class ImportTest < Minitest::Test
def test_empty_json
result = Taskbook::IO.read(StringIO.new('[]'))
assert_equal [], result
end

def test_invalid_json
assert_raises(ArgumentError) do
Taskbook::IO.read(StringIO.new('['))
end
end
end

两个场景分别约束合法空结果与输入错误。空数组表示“没有任务”,异常表示“输入不满足协议”,测试使二者不容易在重构中被合并成同一种返回值。

assert_equal 的期望值放前面,实际值放后面,失败报告才符合读者习惯。assert_raises 需要把会失败的动作放在 block 内;若动作在进入断言之前就执行,测试框架会看到一个意外错误,而不是期望错误已被验证。Minitest 文档 介绍了断言和测试组织方式。

测公共协议,不复制实现算法

Taskbook 的导入测试检查 JSON 与 CSV 的往返结果:

1
2
3
4
5
%w[json csv].each do |format|
encoded = Taskbook::IO.dump(sample, format: format)
decoded = Taskbook::IO.read(StringIO.new(encoded), format: format)
assert_equal sample, decoded
end

它回答“同一合法领域记录能否保留意义”,而不是要求序列化器必须以某个特定内部循环实现。若重构把 map 改成 each_with_object,只要合同没变,这条测试就应该继续通过。

往返测试仍有盲区:编码器和解码器若共享同一个错误,可能互相抵消。因此套件还包含外部字面输入,分别检查顶层类型、字段缺失、字段多出、整数变字符串、未知状态、坏编码和体积上限。不同来源的断言减少“自洽但错误”的可能。

错误测试也应明确拒绝什么。只断言“抛了任意异常”可能把 NoMethodError 当成输入校验成功。这里期望的是公开输入错误类型 ArgumentError,避免程序缺陷被误算成防御生效。需要检查消息时,应优先检查稳定的错误分类,而不是依赖包含临时路径和实现细节的整段堆栈。

别名与资源必须作为结果观察

一个校验函数返回内容相等的数组,还可能把嵌套引用原封不动传给调用者。随后修改原输入标签,会改变此前“已经校验”的结果。套件通过后续修改验证隔离合同:

1
2
3
4
source = sample
copy = Taskbook.validate(source)
source[0][:tags] << 'later'
assert_equal ['ruby'], copy[0][:tags]

这里没有检查对象的某个内部复制方法,而是观察共享引用会造成的实际影响。第 10 篇解释复制与冻结的机制,第 23 篇把需要保留的边界变成回归测试。若未来改成不可变领域对象,应在保持公开合同的前提下更新测试输入,而不是机械坚持 Hash 作为永恒实现。

资源场景同样需要观察关闭结果。只 mock 一个 close 调用,可以证明代码发出了关闭请求,却不能证明文件或 socket 真正进入关闭状态。第 19 篇保留真实 File 对象检查 closed?,第 28 篇实际关闭监听 socket,第 29 篇观察子进程退出。这些测试层次服务不同风险,没有一个统一 mock 能替代它们。

重复 ID 是集合层错误,单个记录本身都合法也可能组成非法集合。套件专门将样本重复拼接,要求校验失败。这说明测试设计需要从合同的层次出发:字段、记录、集合、进程接口各有自己的不变量。

子进程测试约束 CLI 的完整输出

库函数测试看不到退出码与输出流,因此 CLI 通过 Open3.capture3 在子进程中运行:

1
2
3
4
5
6
7
8
out, err, status = Open3.capture3(
RbConfig.ruby, '-Ilib', 'exe/taskbook', 'stats',
stdin_data: Taskbook::IO.dump(sample)
)

assert status.success?
assert_equal 1, JSON.parse(out)['total']
assert_empty err

使用参数数组避免额外 shell 解释;RbConfig.ruby 绑定当前解释器。标准输出只承载可解析结果,错误流应保持为空。非法选项与坏 JSON 则要求非零退出、stdout 为空、stderr 有内容。这些都是自动化调用者真正消费的协议。

输出文件测试先创建一次,再以同一路径调用第二次。第二次必须失败,且读取文件确认第一次内容没变。只检查第二次退出非零,可能漏掉“先截断文件后报错”的数据破坏;反过来,只检查文件还存在也不足以说明内容保持完整。

进程测试比纯函数测试更慢,但它能覆盖解析参数、加载库、编码输出和退出处理的连接处。没有必要把所有字段组合都放进子进程;细粒度输入变化由库测试覆盖,少量端到端场景验证连接正确,既保留信心也控制成本。

负控制如何证明测试入口会报错

labs/23/run.rb 先运行原套件,再创建临时脚本。临时脚本加载相同测试定义,然后替换统计函数:

1
2
3
4
5
module Taskbook
def self.summary(tasks)
{ total: 0, by_status: { todo: 0, done: 0 } }
end
end

Minitest 的自动运行发生在进程结束阶段,所以替换在测试执行之前生效。包含一个任务的 Rack 响应场景应识别出错误总数;CLI 子进程仍加载原实现,不继承这个方法替换。父实验检查子进程退出码与失败标记,只有负控制失败、正例通过时,实验自身才报告通过。

这不是把期望值故意改错后宣布测试灵敏。被修改的是业务行为,原来的契约断言保持不变,因此反例确实检验了测试对一个真实错误类别的观察能力。它也不是完整突变测试:只证明“永远返回零”这个变体会被识别,不证明所有可能缺陷都已覆盖。

如果某条测试偶尔失败,不应通过重试直到绿色来掩盖。首先检查时间、随机性、进程通信和共享状态是否被明确控制。并发测试尤其要用可观察事件建立先后关系,不能依赖“睡眠够久应该就好了”的假设。第 29 篇会在收到子进程 ready 和 accepted 事件后再发信号。

测试之间不要通过顺序交换状态

每个场景应建立自己需要的输入,不能假定前一个测试已经创建文件或填好全局变量。Minitest 可以采用不同执行顺序;依赖偶然顺序的测试会在单独运行或更换种子后失败。样本函数每次返回新数组,临时目录每次独立创建,使状态归属保持局部。

测试随机种子不是错误原因的替代解释。发现某个种子失败时,要保留该种子和最小场景,检查共享状态、时钟、文件路径或环境变量在哪里泄漏。只固定一个“能通过的种子”,会把真实相互影响留在工程里。对于并发或随机算法,固定种子只是可重现输入的一部分,调度本身还需要额外控制。

断言数量也不能直接代表质量。五十个检查同一个常量值的断言,可能没有一个覆盖输出被截断后的文件内容。测试设计应先列公开结果与失败后果,再选择最少但有区分度的观察。Taskbook 的拒覆盖场景同时看退出码和旧内容,是因为两个观察排除不同错误。

测试替身适合把难控制的外部系统缩小为协议,但替身本身需要与真实边界对齐。如果替身允许任意方法,业务代码拼错方法名也可能继续通过。对于本地可控资源,真实临时文件、真实子进程和回环 socket 往往成本足够低,可以直接覆盖连接处,而无需新增大量模拟框架。

重构完成后,应删除只约束已消失实现细节的断言,保留公开合同。删除需要依据合同变化,而不是因为某条测试失败难修。测试和实现同时改变时,错误实现负控制能补充信心,但它也只能验证实际构造的错误类别,不能成为泛化的“已经证明没有缺陷”。

测试入口也要有明确的工作目录与依赖环境。本套件要求在 examples/ruby 下通过 bundle 运行,避免从全局加载另一个库版本。失败日志保留用例名与断言差异,成功日志保留运行数和退出状态,使后来复核能够重跑同一条命令。

运行与练习

1
2
3
cd examples/ruby
bundle exec ruby -Ilib test/engineering_boundaries_test.rb
bundle exec ruby -Ilib labs/23/run.rb

第二个命令包含预期失败输出;应以整个实验退出零并出现 PASS 23 作为判定,而不是要求日志中不能出现单词 Failure。实际记录在 evidence/23/run.txt,其中正例和负控制分开保留。

练习一:新增一个合法 CSV 字段内含逗号与换行的样本,验证往返后标题没有被拆成多条记录。练习二:在临时变体中让校验函数共享 tags 数组,确认别名测试失败,再恢复深复制。为新错误保留能观察其后果的断言,避免把修复代码逐句翻译成测试。