手工测试用例覆盖的是写测试的人想得到的错误。想不到的错误藏在组合空间的角落里,等着在生产环境被触发。前面二十篇积累了解释器、编译器、优化 pass 和源码映射,手工正反例加起来几百条,仍然不能保证它们之间没有分歧。

本篇用随机生成的 Sprout 程序同时喂给解释器和编译后的本机二进制,比较两边的输出。任何不一致都意味着 bug。找到之后自动缩减失败样本,留下最小复现,纳入回归语料。

差分测试:两条路径,一个判定

差分测试的核心逻辑只有一句话:同一个程序经过两条独立的执行路径,结果必须一致。

1
2
3
4
5
6
7
8
9
随机生成合法 Sprout 程序 P

├─ 解释器(第 07 篇)执行 P → 退出值 A, stdout A, 错误类别 A

└─ 编译器 → 本机二进制 → 执行 → 退出值 B, stdout B, 错误类别 B

比较 (A, B)
一致 → 通过
不一致 → 记录 P,进入缩减流程

比较的不是错误消息的字面文本。解释器报 division by zero at line 5,编译后的运行时报 runtime error: div_zero,这两条消息不同但错误类别相同,算通过。比较三个维度:退出值(整数)、标准输出(逐字节)、错误类别(成功、运行时错误的子类、类型错误)。错误消息的措辞差异不计入失败。

参考解释器和编译器共享词法、语法与类型检查。差分测试检验的主要是降低、优化与后端引入的错误。共享前端自身的 bug——比如类型检查漏放了某种非法程序——不会被差分测试抓到,仍然需要手工定义的正反例。

随机生成合法程序

对着 rand() 拼字符串只会产出大量语法错误。有效的做法是按照 Sprout 文法从根节点递归生成,每一步从当前非终结符的产生式中随机选择一个展开。

生成器需要两个约束参数:深度上限控制嵌套层数,节点上限控制程序总规模。到达上限时,只允许选择终止产生式(字面量、变量引用),防止无限递归。

1
2
3
4
5
6
7
8
gen_expr(depth, ctx):
if depth == 0:
return 随机字面量 或 ctx 中的变量引用
choice = random(binary_op, unary_op, call, if_expr, literal, var)
match choice:
binary_op → gen_expr(depth-1) op gen_expr(depth-1)
call → 选 ctx 中已有函数签名,生成对应类型实参
...

类型正确性在生成时保证,而不是生成后过滤。每一步维护当前上下文中可用的变量及其类型,只生成类型匹配的子表达式。生成 if 条件时选 bool 表达式,生成算术运算时选 i64 操作数。这样大部分生成结果都能通过类型检查,命中率远高于盲目拼接后再丢弃不合法的程序。

生成带控制流的完整函数比只生成表达式更复杂:需要管理变量声明、可变绑定、while 循环的条件与循环体、return 的位置。初始阶段先只生成表达式和单函数程序,验证基础设施工作后再逐步扩展生成器。

执行限制

随机程序可能包含死循环或极深递归。不设限制的话,测试进程会挂起或耗尽栈空间。每次执行都要施加两道防线:

  • 时间限制:1 秒超时。解释器和本机程序各自独立计时。超时视为该程序不可判定,跳过,不计入失败。
  • 递归深度限制:解释器内维护调用深度计数器,超过阈值(如 256)时产生运行时错误。编译后的程序依赖操作系统的栈大小限制;为保持一致,运行时库也插入深度检查。

两条路径对同一程序都超时,不算分歧。一条超时一条正常完成,也跳过——超时行为不在语义规范内。只有两条路径都在限制内正常完成且结果不同,才算真正的差分失败。

属性测试

差分测试比较的是两条路径之间的一致性。属性测试检查的是单条路径自身应满足的不变量:

  • 优化不改变语义:对同一程序,O0 编译结果与 O2 编译结果的输出必须相同。
  • 类型检查拒绝非法程序:故意生成类型不匹配的程序(把 bool 塞进 i64 参数位置),类型检查器必须报错。
  • 解析往返:解析源码得到 AST,再把 AST 打印回源码,再解析一次,两棵 AST 结构等价。
  • SSA 验证器通过:任何经过优化 pass 的 SSA 都必须通过第 12 篇的 verifier——定义支配使用、phi 来边正确、类型一致。

属性测试与差分测试互补。差分测试发现"两边不一致",属性测试发现"单边违反不变量"。两者共享同一套随机生成器。

失败样本缩减

随机生成的失败程序往往有几十行,包含大量与 bug 无关的代码。直接把它丢进 issue tracker 没人愿意看。缩减的目标是找到仍然触发同一失败的最小程序

缩减策略按粒度从粗到细:

  1. 删除整个函数(非 main)。
  2. 删除 main 中的单条语句。
  3. 把子表达式替换为同类型的字面量。
  4. 把变量引用替换为常量。

每一步之后重新运行差分比较。如果失败仍然复现,保留这次简化;否则回退,尝试下一个候选。循环直到没有任何单步简化能进一步缩小程序。

最终得到的最小程序通常只有三到五行,bug 的根因一目了然。这个缩减后的程序就是回归测试用例。

回归语料与 CI

每个缩减后的失败样本进入 tests/regression/ 目录,附带预期的退出值、标准输出和错误类别。CI 在每次提交后运行全部回归用例。修复一个 bug 时,对应的回归用例从失败变为通过;如果未来的改动让它重新失败,CI 立刻报红。

回归语料只增不减。即使当初触发 bug 的优化 pass 被重写了,保留用例仍有价值——它覆盖的输入模式可能在新代码中以不同方式再次出错。

注入错误验证测试基础设施

测试基础设施本身也可能有 bug。如果差分比较函数写错了——比如只比较退出值忘了比较 stdout——真正的 bug 就会从裂缝里溜走。

验证方法:在某个优化 pass 中故意注入一个错误。例如把常量折叠中 a - b 改成 a + b。然后运行差分测试。如果测试没有报告任何失败,说明基础设施有盲区,需要排查。注入错误后测试必须检出,这是测试系统自身的验收条件。

注入实验不需要改动主线代码。用一个编译期开关或单独的测试配置控制,跑完即恢复。它的作用类似于消防演习:确认报警系统真的能响。

练习

设计一个只生成 Sprout 表达式的文法引导生成器,支持 i64 字面量、二元算术运算(+-*/)和括号。深度上限设为 5,每次生成一个 fn main() -> i64 { return <expr>; } 形式的程序。运行 1000 个生成程序,分别通过解释器和编译后的二进制执行,统计通过数、跳过数(超时或除零两边一致)和失败数。如果出现失败,手工缩减到最小复现。

上一篇:20 - 让错误回到源程序。下一篇:22 - 测量编译与运行性能