测试用例简化器是被低估的调试工具
摘要
这篇博客文章解释了测试用例简化器在调试中的价值,详细介绍了它们如何自动化输入简化以隔离错误,并探讨了考虑错误频率或指令计数等高级技术。
<p><a href="https://lobste.rs/s/zwn4xe/test_case_reducers_are_underappreciated">评论</a></p>
查看缓存全文
缓存时间: 2026/06/09 12:43
# 测试用例缩减器:被低估的调试工具
来源:https://tratt.net/laurie/blog/2026/test_case_reducers_are_underappreciated_debugging_tools.html
测试用例缩减器(test-case reducer)的知名度远不及它应有的水平,而即便是那些了解它的人,也未必意识到我们可以用多少种方式来使用——甚至“滥用”——它们。在本文中,我将探索自己在使用这些出色工具时学到的一些东西。我会从最基础的部分讲起,因为这个理念简单到让人难以相信它真能奏效。然后我会逐步深入,直到一个更令人惊讶的层面。测试用例缩减器试图缩短输入长度,但我们也可以迫使它们考虑额外因素,比如错误出现的频率,或者执行的指令数量。根据你要调试的问题类型,这可能会让缩减器的实际效果产生巨大差异。我不指望本文中的内容会让专家感到惊讶,但既然我是靠自己磕磕绊绊才学会这些的,也许把它们集中放在一个地方能让其他人受益。
## 测试用例缩减
假设我们写了一个程序,它在处理一个大型输入时会崩溃,而我们不知道输入的哪一部分导致了崩溃:这时我们能做什么?大多数人可能首先会用经典的调试技术来排查程序,从快速且粗糙的方式(`printf`)逐步过渡到更系统化的方法(调试器),再——如果我们既绝望又有经验——到“小众”工具(消毒器、valgrind 等)。每个程序员都会积累一套自己习惯使用的调试工具和技术,其中一些比其他更有效1。有一种技术其实应该更常用,但却很少被人使用,那就是**缩减**输入的大小。几乎在所有情况下,输入越小,我们就越容易找出它导致问题的原因。
缩减可以手动进行。我们可以把输入加载到文本编辑器中,删除一部分,然后看看这个新的、更小的输入是否仍然会导致崩溃。毫不意外,作为容易厌倦且视野有限的人类,我们在手动缩减时往往会错过很多缩减的机会。而且,经常发生的情况是:删除输入的一部分后,程序不再以我们正在研究的那个方式崩溃——缩减后的程序可能会正常运行,或者在新输入上抛出一个不同的、正确的、预期的错误。更糟糕的是,有时你会发现,仅仅删除输入中的 A 部分并不能达到我们想要的效果,但删除不相邻的 A 和 B 部分却可以:这样折叠的搜索空间该有多大?一个西西弗斯式的未来在召唤着我们。
## 测试用例缩减器
幸运的是,有一些工具可以自动执行测试用例缩减的过程:**测试用例缩减器**2。它们接受一个程序、一个输入以及一个**有趣性测试**(interestingness test)。测试用例缩减器会尝试越来越短的输入版本,而有趣性测试则告诉缩减器,这些更短的版本是否仍然会触发你关心的那个问题。测试用例缩减器的效果惊人——通常能缩减 95-99%——并且往往能让调试变得极其容易。
测试用例缩减器听起来像是魔法:一个工具怎么知道输入中哪些部分可以删除?更糟的是,最彻底拥抱这些工具的群体是编译器作者,而许多程序员视编译器作者为技术水平高不可攀的精英3。在我看来,这两件事的结合让许多人望而却步,不敢尝试这类工具。
好消息是:测试用例缩减器并非魔法。要理解这一点,最简单的方法就是自己写一个。假设我写了这样一个程序,它从一个文件中读取单词:
```python
import sys
for l in open(sys.argv[1]):
if len(l) > 25:
print("Word too long\n")
```
当我在自己的机器上使用 `/usr/share/dict/words` 运行它时,它会打印一条警告:
```
$ python3 t.py /usr/share/dict/words
Word too long
```
为了举例,我们假设看到那条警告是一个“错误”,而我们使用传统调试技术一直没能找出它崩溃的原因。测试用例缩减器能帮上忙吗?
我们首先要做的是定义有趣性测试。我打算沿用我所熟悉的测试用例缩减器的约定,让有趣性测试成为一个程序,其行为如下:
- 如果输入是“有趣的”——也就是说,输入体现了我们关心的错误——则返回 0,而且我们应该继续使用这个缩减后的输入。
- 如果输入是“无趣的”,则返回非 0,并需要尝试另一种缩减方式。
我将写一个简单4的 shell 脚本,它把文件名作为第一个参数,运行上面的程序,并检查其输出是否包含“Word too long”。如果有,我的有趣性测试就返回 0;否则返回 1。
```bash
#!/bin/sh
if python3 t.py "$1" | grep "Word too long" > /dev/null; then
exit 0
else
exit 1
fi
```
现在我们需要完成最困难的部分:缩减器!其实这并不太难:
```python
#!/usr/bin/env python3
import subprocess, sys, tempfile
cur = [x.rstrip() for x in list(open(sys.argv[2]))]
i = 0
while i < len(cur):
with tempfile.NamedTemporaryFile(mode="w") as p:
cnd = cur[:]
del cnd[i]
p.write("\n".join(cnd))
p.flush()
if subprocess.run([sys.argv[1], p.name]).returncode == 0:
cur = cnd
else:
i += 1
print("\n".join(cur))
```
本质上,这段代码先把文本输入按行加载进来(`cur`),然后循环遍历输入。每次迭代都构建一个候选输入(`cnd`),它相对于当前起始点删除了其中一行(`del cnd[i]`)。我们创建一个临时文件,将候选输入写入其中,然后运行我们的有趣性测试。如果测试返回 0,我们就保留该候选作为新的起始点(`cur = cnd`);否则就尝试下一行(`i += 1`)。当无法再进行缩减时,将缩减后的输入打印到 `stdout`。
说这个实现慢都是轻的,但运行之后,它最终5会打印出:
```
$ python3 reducer.py ./interestingness.sh /usr/share/dict/words
antidisestablishmentarianism
```
当我第一次接触到测试用例缩减器的概念时,我短暂地怀疑过:这么简单的东西怎么能有用?确实,上面的例子可能让整个想法看起来微不足道:我原始程序中的“错误”实在太明显了,我根本不需要这种额外的工具!在我看来,这种想法会让人忽视测试用例缩减的核心要点:我的缩减器并不理解我的有趣性测试,更不理解有趣性测试所运行的那个底层程序。换句话说,测试用例缩减虽然几乎不知道**为什么**它做的事情有用,但却实实在在地做了有用的事。这种“不理解”正是测试用例缩减器成功的关键。
我可以在任何文本文件上运行我的测试用例缩减器,它都会工作6。快速证明一下:我让我最喜欢的 LLM 生成一个**大小适中的 C 程序**,以便进行测试用例缩减(https://tratt.net/laurie/blog/extra/2026/test_case_reducers/t.c),我直接拿了它给我的第一个结果。这个问题是一个经典案例:一个程序在两种不同配置下(`FAST=0` 和 `FAST=1`)会意外地产生两个不同的输出。这个 C 文件有 78 行,我定义了如下的有趣性测试:
```bash
#!/bin/sh
set -eu
cp "$1" t.c
cc -std=c99 -O2 -DFAST=0 t.c -o slow
cc -std=c99 -O2 -DFAST=1 t.c -o fast
slow_out="$(timeout 1s ./slow)" || exit 1
fast_out="$(timeout 1s ./fast)" || exit 1
test "$slow_out" = "0d754a56" || exit 1
test "$fast_out" != "0d754a56" || exit 1
```
我稍后会再回到这个测试为什么比第一个要长一些。然后我对缩减器做了一个快速修改:
```python
c = subprocess.run([sys.argv[1], p.name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
```
这样可以避免子命令的输出干扰我。然后我运行缩减器:
```
$ python3 reducer.py ./interestingness.sh t.c > t2.c
$ wc -l t2.c
54
```
缩减器运行了不到 10 秒,在这短短的时间里,它将输入的大小(按代码行数)减少了 30%!而且,是的,它确实保留了我关心的那个错误。
缩减器的一大优点是,如果你愿意让它运行更长时间,它们可以做得更好。我们对缩减器做一个非常简单的改动:每次我们找到一个有趣的输入时,就用这个新输入重新开始,从头开始删除行——换句话说,现在我们能够重新尝试那些之前无法删除的行了。只需在适当位置加上 `i = 0`:
```python
if c.returncode == 0:
cur = cnd
i = 0
```
现在运行时间几乎增加了 10 倍,但它又缩减了 3 行输出。可以想象,对缩减器的进一步改进会做得越来越好。我不打算再详细描述这些改进了,因为我们可以直接使用现成的缩减器,它们已经实现了你能想到的所有技巧——甚至更多!
我特别喜欢 **Shrink Ray**(https://github.com/DRMacIver/shrinkray),它不仅包含许多强大且合理的缩减规则,还能并行运行测试用例缩减:
```
$ pipx install shrinkray
$ shrinkray --no-clang-delta ./interesting.sh t.c
```
通过添加 `--no-clang-delta`,我阻止了 Shrink Ray 利用它对 C 语言的任何特殊知识。换句话说,我让它对我正在缩减的语义保持与我写的那个简单缩减器一样的无知7。Shrink Ray 有一个漂亮的 UI,这很好,因为它在我们的例子上会慢慢运行一段时间:
(此处应有图)
如你所料——考虑到图中 y 轴是对数刻度——它在运行一段时间后不可避免地会遇到收益递减,因此尽管它在几分钟内完成了大量缩减,但要完全用尽它的“武器库”却需要几十分钟。大约 15 分钟后,它结束了工作,将输入的大小减少了超过 60%(按字节计)!有必要重申一下这有多么令人印象深刻:我给了一个随机的 C 程序,加一个小的 shell 脚本,它就把输入减少到让调试变得容易得多的程度。
Shrink Ray 的花招非常巧妙。有些相当明显——例如,它知道标准的注释语法,会尽早尝试删除它们——但有些则出人意料。我最喜欢的是,当它开始把较大的整数缩减成较小的数值时,常常会让调试出人意料地变轻松。我强烈建议你观看它的运行过程,因为看看它尝试的缩减会很有启发性。当 Shrink Ray 找到少数几个缩减步骤,从而突然解锁更多缩减时,我觉得特别令人满足。下面是我最近一次随机运行的截图:
(此处应有图)
我之前花了不少时间尝试调试那个特定的输入,结果只是让自己淹没在细节中。大约 20 分钟后,Shrink Ray 找到了那把“魔法钥匙”,让输入的大小减少了 90%,然后继续一点点打磨,直到减少了 99%。突然间,那个 bug 就跳了出来。这是测试用例缩减中的常见体验。
## 编写有趣性测试可能很困难
让我们回到我之前定义的第二个有趣性测试:
```bash
#!/bin/sh
set -eu
cp "$1" t.c
cc -std=c99 -O2 -DFAST=0 t.c -o slow
cc -std=c99 -O2 -DFAST=1 t.c -o fast
slow_out="$(timeout 1s ./slow)" || exit 1
fast_out="$(timeout 1s ./fast)" || exit 1
test "$slow_out" = "0d754a56" || exit 1
test "$fast_out" != "0d754a56" || exit 1
```
这看起来可能比你预期的要复杂一些:原因是痛苦的经历让我认识到,编写次优甚至完全错误的有趣性测试是多么容易。即使是这个简短的例子也蕴含了几个重要的教训。
首先也是最重要的一点:测试用例缩减器是有趣性测试的忠实驱动者。很容易让有趣性测试接受它不应该接受的东西,这会导致缩减器缩减到你希望它停下的点之后。**过度缩减**如此常见,以至于 Shrink Ray 会明确检查有趣性测试是否接受了一个空输入——我碰到这个检查的频率高得令人担忧。在上面的例子中,似乎我只需要检查两个编译后程序的输出不同(`test "$slow_out" != "$fast_out"`)就行了。但是,这样做会将那些不重要、很可能具有误导性的输出差异也归类为有趣的。第一个检查 `test "$slow_out" = "0d754a56"` 确保了慢版本的程序确实产生了它应有的输出,从而大大降低了这种可能性8。
第二,你的测试用例运行得越快,整体缩减速度就越快。如果足够快,测试用例缩减器一秒钟可以运行有趣性测试上百次!因为即使中等规模的例子也可能导致数十万次缩减尝试,这真的会积少成多。特别是对于大型输入——缩减器需要尝试很多次——我发现值得花点心思让有趣性测试尽可能优化。有些“优化”可能很不常规:我最近通过关闭自动创建核心转储文件,把一个有趣性测试加速了大约 3 倍。
第三,测试用例缩减器会愉快地删除像 `i -= 1` 这样的行,从而把终止的程序变成非终止的程序。如果你想知道为什么你的缩减器不进展,这几乎肯定是原因。Shrink Ray 有一个全局超时,但正如上面的例子所示,我经常不止一次运行测试输入。由于有趣性测试需要尽可能快地运行,我会设置“子超时”来尽早检测到无趣的输入。人们很容易设置一个非常保守的超时,比如 60 秒,但如果我们要分析的程序在 0.1 秒内就跑完,我们会降低几个数量级的整体缩减速度,却几乎或根本没有收益。我倾向于在缩减前快速测量程序的运行时间:如果它非常快,我会把 `timeout` 设为 1 到 2 秒;否则我会把 `timeout` 设为它初始运行时间的大约 1.5 到 2 倍。
最后一个需要考虑的重要因素是:像 Shrink Ray 这样的缩减器会并行运行有趣性测试9。幸运的是,在这个特定例子中,Shrink Ray 已经做好了所有我需要的事情:它在临时目录中运行每个有趣性测试(并且会自动清理)。遗憾的是,这通常还不够,但具体需要做些什么则因情况而异。
## 利用有趣性测试鼓励确定性
假设我有一个棘手的输入,某处包含以下片段:
```python
x = 1
y = len([])
if random.random() < 0.33:
print(x / y)
```
这里一个明显的问题是,它包含一个除零错误(`len([]) == 0`)。不那么明显的问题是,这个棘手的结果只在大约 1/3 的运行中出现。非确定性 bug 是程序员生活中的一大困境:由于 bug 随机出现和消失,它使得验证关于错误的假设比正常情况更困难、更耗时。如果我们幸运的话,在某些时候,我们的缩减器会删除掉 `random.random()` 调用(它可能会完全删掉 `if` 行10,或者替换条件的一部分)。这样一来,它就把一个非确定性错误变成了一个确定性错误,使得 bug 更容易理解和修复。
我花了一点时间才意识到:测试用例缩减器可以降低输入中的非确定性水平——往往能降低到零!不幸的是,现实世界中的非确定性有时很难消除。非确定性 bug 通常涉及输入的多个部分以一种不幸的方式交互。很容易以某种方式缩减输入,这种方式并不会提高、甚至可能恶化 bug 出现的频率。在调试 **yk**(https://tratt.net/laurie/blog/2026/retrofitting_jit_compilers_into_c_interpre...)时,我经常看到这种情况发生。
相似文章
发现缺陷
这篇博客文章探讨了生成式测试与单元测试在发现缺陷方面的有效性,以Rust的regex crate为例。它展示了一个自定义模糊测试器如何发现了多个缺陷,并提供了改进测试方法的技术。
开发了一个本地工具,用于缩小成功但错误运行的范围
作者构建了一个名为Traser的本地浏览器工具,帮助工程师诊断那些完成无错误但产生错误结果的运行。通过比较执行步骤并提供基于证据的调查点。
程序员据传不喜欢`reduce`
作者非正式地观察到,与`map`和`filter`相比,程序员不喜欢使用`reduce`函数,原因包括可读性和熟悉度。
我厌倦了手动调试追踪
一位开发者构建了一个AI代理调试工具,通过比较重放与参考运行来识别行为首次偏离的位置,表达了对手动追踪调试的挫败感。
当你不再理解代码时,如何高效调试
Cindy Sridharan 分享了在 AI 编码智能体的辅助下调试大型陌生代码库的实用技巧,结合三个真实的生产环境 Bug 案例,并提出了在"智能体原生"开发工作流中平衡自主性与责任归属的框架。