系列导航

导读 · 上一篇:23:Minitest 与可失败的行为测试 · 下一篇:25:RBS、Steep 与静态检查边界 · 完整源码包

停在计算现场,检查当前帧

统计结果不符合预期时,第一步是检查输入和中间值,而不是立即修改算法。一个断点可以把“这个变量应该是二”变成对实际运行帧的观察:

1
2
3
4
5
6
7
def report_total(tasks)
total = tasks.length
binding.break
total
end

puts report_total([:one, :two])

用固定 debug 版本启动该文件,执行 p total 和 bt,就能分别看到局部变量与调用栈。继续执行后,程序正常输出结果。断点位置在赋值之后,因此局部变量已经建立;把断点移到赋值之前,就不能用同一个预期解释观察结果。

本篇前置是方法查找、异常和行为测试。冻结依赖为 debug 1.11.0,完整被调试文件在 examples/ruby/labs/24/probe.rb,自动验证入口为 labs/24/run.rb。本例只使用本地终端输入,不启动远端调试端口。

调试命令属于调试器,表达式仍在程序环境执行

p total 请求调试器在暂停上下文中求值,bt 显示栈,continue 恢复运行。它们不是加入业务程序的 Ruby 语句。把调试会话抄进源文件,和把 binding.break 留在正式执行路径,都可能让自动化命令等待输入。

调试器具备读取和修改当前进程状态的能力。表达式如果调用有副作用的方法,就会改变接下来要诊断的行为。检查 tasks.length 通常简单;调用“重新导入”“发送请求”之类方法则可能重复业务动作。观察器并非完全不影响被观察对象,调试时应优先读局部数据和明确的只读属性。

局部变量依附于当前帧。递归或多层同名方法调用中,多个帧可能都有名为 tasks 的变量。先看栈、确认源文件和行位置,再解释局部值,能避免在错误帧上得出正确但无关的结论。第 12 篇的方法 owner 与 source_location 适合处理“究竟调用了哪个实现”,调试帧则补充“这次调用携带什么状态”。

debug 项目文档 说明断点、步进和栈操作。不同工具版本的界面文字可能变化,因此实验不把整屏字符逐字锁死,而是检查核心行为与当前版本实际输出。

把交互操作变成有界的可重跑场景

实验使用标准输入发送固定命令:

1
2
3
4
5
6
out, err, status = Open3.capture3(
{ 'RUBY_DEBUG_NO_COLOR' => '1',
'RUBY_DEBUG_HISTORY_FILE' => '/dev/null' },
RbConfig.ruby, '-rdebug', 'labs/24/probe.rb',
stdin_data: "p total\nbt\ncontinue\n"
)

无颜色输出更适合保存文本证据,历史文件定向到空设备避免把调试操作写入个人历史。实验检查进程成功退出,输出包含当前方法帧与实际求值结果。完整会话保存在 evidence/24/run.txt,读者可以核对断点位置和输出,而不是只相信一个抽象 PASS。

自动输入不是所有交互问题的通用解决方案。调试程序如果新增第二个断点,原来的命令序列可能在不同暂停点被消费,甚至停住。自动化调试场景必须控制被调试文件,限制暂停次数,并让外层验证具备超时与清理策略。实际排查大型应用时可以使用交互终端,但证据仍需说明停在哪一帧、读取了哪些值。

命令本身也不能证明所有业务分支正确。这个实验只验证一个含两个元素的数组进入统计方法、变量为二、栈可追踪、继续后程序能退出。错误输入、多线程调试或远端连接不在本次覆盖范围。

语法检查不执行主体

在运行前,ruby -c file.rb 可以判断源文件是否能被解析。实验在临时文件中写入未闭合的方法参数列表,要求检查返回非零:

1
2
3
File.write(file, 'def missing(')
out, err, status = Open3.capture3(RbConfig.ruby, '-c', file)
raise 'syntax failure not detected' if status.success?

这种检查能快速定位括号、关键字结构和其他语法问题。它不能证明方法存在、文件能打开、依赖可加载或输出计算正确。如下代码语法成立,执行时仍会因为未知方法而失败:

1
2
object = Object.new
object.method_that_does_not_exist

将语法检查与行为测试串起来,是因为它们捕获不同层次的问题。语法检查成本低、失败位置直接,适合先跑;行为测试随后验证具体合同。两者都通过,也不能自动证明程序在所有输入上正确,覆盖边界仍取决于测试场景。

同样,编辑器没有显示红线不等于检查通过。编辑器可能没有启动对应 LSP、没有选择当前 Ruby 版本,或只进行了局部分析。本工程直接记录命令行检查结果,不把缺失的语言服务器视为已完成的验证,也不为教程额外安装一整套编辑器配置。

警告能揭示问题,也需要解释场景

开启 -w 后,重复定义常量会产生警告:

1
ruby -w -e 'KNOWN = 1; KNOWN = 2'

程序可以退出零,同时 stderr 包含 already initialized constant。这说明“非空 stderr”和“进程失败”没有必然等价关系。测试应分别检查退出状态与诊断内容,避免把所有警告都忽略,或把允许的警告错误分类为异常。

重复常量定义可能来自手工重载、文件加载关系不清晰,也可能来自实验刻意演示。诊断只提供线索,修复要追到具体来源。如果因为 load 重复执行顶层声明,简单屏蔽警告会隐藏第 20 篇的生命周期问题;如果实验就是要验证重复定义,保留警告并标注预期更诚实。

警告受 Ruby 版本、分类和开关影响。比较两次输出时,需要记录运行命令与解释器,不应把某版本没有打印警告推导成规则从未存在。依赖升级引入警告时,应先判断是 API 弃用、解析行为变化还是调用方式问题,再安排对应修复。

生产程序的 stderr 合同也需要明确。用户输入错误与内部诊断可能共享输出流,但必须避免泄露机密或让机器输出混杂在 stdout。Taskbook 的正常命令测试要求 stderr 为空,故意产生警告的实验则另起进程,避免污染普通入口。

静态工具与格式工具的证明能力

静态分析可以在不运行某些路径的情况下指出疑似问题,格式工具可以减少样式差异。这两种能力都不能替代领域校验。例如 priority 必须位于一到五,即便它被静态检查为 Integer,仍可能是负数。反过来,行为测试只输入合法数值,也不会自动发现整个接口缺少类型信息。

本系列本篇不额外引入 RuboCop;Ruby 自身语法检查、警告和 debugger 已覆盖当前需要展示的诊断层。第 25 篇单独引入 RBS 与 Steep,区分签名声明、静态检查范围和运行时输入验证。减少工具数量是为了让每项结果能对应明确问题,而不是把“工具越多”当成质量指标。

调试器通常用于定位已经观察到的问题,回归测试用于防止它再次出现。修复完成后,应把触发错误的最小输入和可观察结果写进测试,再移除断点。否则下一次程序更新仍依赖人工重走同一套调试命令,知识没有进入验证入口。

不要为了使检查变绿而删掉有价值的反例。若静态工具不能表达某种动态路径,先缩小被检查范围并声明限制,或把动态边界改成可表达的协议。第 25 篇会用独立 TypedTasks 子集说明这个取舍,而不把未检查路径冒充安全路径。

从调试发现回到可提交的证据

一个调试会话最有用的产物是可重现输入、实际执行位置和违反的合同。如果只保存一张变量窗口截图,后来实现行号变化,就很难重新定位。实验同时保存被调试源文件、启动命令和会话文本,使观察可以与版本对应。

还应区分“看到了中间值”和“证明因果”。变量 total 为二,只说明断点处的当前值。最终报告错误可能发生在之后的序列化、聚合或输出选择。沿调用链继续检查边界,直到找到首次偏离预期的点,才能把修复放在正确位置。随意在最终输出上减一,可能只掩盖某个输入下的症状。

异常堆栈也要按发生顺序解读。最外层入口告诉读者任务从哪里开始,最接近异常的帧通常指向直接失败动作;原因链则可能包含更早的底层错误。把堆栈最后一行复制成诊断结论,容易忽略异常包装已经改变了可见位置。

断点里的局部修改只能作为假设实验。即便把一个值改正确后流程成功,也只支持“这个值影响了结果”,尚未解释它为什么产生错误。最终修复应回到负责产生或验证该值的代码,并用原始输入重跑完整场景。这样调试才从临时操作转成可维护的工程结论。

运行与练习

1
2
cd examples/ruby
bundle exec ruby labs/24/run.rb

成功要求本地调试会话完成、未闭合语法被拒绝、重复常量产生预期警告。日志中的错误诊断属于负场景;实验整体成功行以 PASS 24 开头。

练习一:把断点移动到 total 赋值之前,比较求值结果与栈位置,解释局部变量可见性和赋值状态的区别。练习二:故意让统计返回 tasks.length + 1,先用 debugger 定位,再增加一条行为断言;移除断点后仍应由测试识别这个错误。