差分测试到底能给你带来什么
你可以在没有形式化证明的情况下信任差分测试,但前提是你必须清楚它究竟会在哪里失效。
这个弱点叫做共模故障(common-mode failure)。当某个规范的每一个实现都做出同样的错误假设时,它们会全部达成一致,而你的测试框架会将其判定为通过。N 版本编程(N-version programming)并不能保护你免受糟糕规范的侵害。
差分测试的工作原理是:针对相同的输入,运行多个相互独立的、基于同一规范的实现。如果它们的输出不一致,至少有一个存在 bug;如果它们一致,你暂时认为它是正确的。
这很强大,因为它消除了对测试预言机(test oracle)的需求。预言机是一种能够知道每个输入对应正确答案的真理来源。对于复杂系统而言,预言机往往比系统本身更难构建。税务引擎、物理模拟器或协议解码器,在你能够证明每个输出应该是什么之前,就可以先通过一致性来进行测试。
但这种试探性很重要。达成一致只能证明一致性,不能证明正确性。
为什么一致不等于正确
每个人都在理论上学过、却在实践中忘记的失效模式就是共模故障(common-mode fault)。当错误源于规范本身,或者源于所有实现团队独立做出的共同假设时,每个版本都会产出同样的错误答案。
规范不必以明显的方式出错。它只需要在某个边缘案例上存在歧义,而人类大脑恰好以相同的方式去解释它。
想象一个规范:给定顶点列表,计算简单多边形的面积。规范提供了鞋带公式(shoelace formula),但从未提及顶点顺序。
三个团队分别实现。他们都假设顶点按逆时针排列,因为示例图就是这样画的。顺时针输入会在原始公式中产生负面积。三个团队都悄悄用 abs() 包裹结果,因为面积必须是正数。他们在每个测试用例上都达成了一致。
但规范从未说过顺时针是无效的。这些实现是一致的,却因遗漏而错误。差分测试给它们全部开了绿灯。
三个实现,一个模糊的规范
这里有一个你可以实际运行的具体例子。规范写道:“解析一个持续时间字符串并返回总秒数。持续时间由一个或多个组件组成。每个组件是一个正整数后跟一个单位字母:h 表示小时,m 表示分钟,s 表示秒。”
三个团队收到这份规范,各自编写了解析器。
import re
def parse_duration_a(s):
"""Team A: regex approach."""
if not isinstance(s, str):
raise TypeError("input must be a string")
m = re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?", s)
if not m or not any(m.groups()):
raise ValueError(f"invalid duration: {s}")
h, mn, sec = (int(x or 0) for x in m.groups())
return h * 3600 + mn * 60 + sec
def parse_duration_b(s):
"""Team B: left-to-right scanner."""
if not isinstance(s, str):
raise TypeError("input must be a string")
total = 0
i = 0
while i < len(s):
j = i
while j < len(s) and s[j].isdigit():
j += 1
if j == i:
raise ValueError(f"expected number at position {i}")
num = int(s[i:j])
if j >= len(s):
raise ValueError(f"missing unit after {num}")
unit = s[j]
if unit == 'h':
total += num * 3600
elif unit == 'm':
total += num * 60
elif unit == 's':
total += num
else:
raise ValueError(f"invalid unit: {unit}")
i = j + 1
return total
def parse_duration_c(s):
"""Team C: state machine with duplicate detection."""
if not isinstance(s, str):
raise TypeError("input must be a string")
total = 0
seen = set()
i = 0
while i < len(s):
j = i
while j < len(s) and s[j].isdigit():
j += 1
if j == i:
raise ValueError("expected number")
num = int(s[i:j])
if j >= len(s):
raise ValueError("missing unit")
unit = s[j]
if unit in seen:
raise ValueError(f"duplicate unit: {unit}")
seen.add(unit)
i = j + 1
if unit == 'h':
total += num * 3600
elif unit == 'm':
total += num * 60
elif unit == 's':
total += num
else:
raise ValueError(f"invalid unit: {unit}")
return total
现在我们运行一个差分测试框架,向三个实现输入相同的数据,并标记出不一致的地方。
def differential_test(implementations, inputs):
for case in inputs:
results = []
errors = []
for impl in implementations:
try:
results.append(impl(case))
except Exception as e:
errors.append(type(e).__name__)
if errors:
if len(errors) == len(implementations) and len(set(errors)) == 1:
print(f"ALL ERROR on {case!r}: {errors[0]}")
else:
print(f"MIXED on {case!r}: results={results}, errors={errors}")
else:
if len(set(results)) == 1:
print(f"AGREE on {case!r}: {results[0]}")
else:
print(f"DISAGREE on {case!r}: {results}")
IMPLS = [parse_duration_a, parse_duration_b, parse_duration_c]
CASES = [
"1h30m", # normal
"90m", # single unit
"30m1h", # out of order
"1h2h", # duplicate unit
"0h", # zero is not positive
"1.5h", # decimal
"1H", # wrong case
]
differential_test(IMPLS, CASES)
运行结果如下:
AGREE on '1h30m': 5400
AGREE on '90m': 5400
MIXED on '30m1h': results=[5400, 5400], errors=['ValueError']
MIXED on '1h2h': results=[10800], errors=['ValueError', 'ValueError']
AGREE on '0h': 0
ALL ERROR on '1.5h': ValueError
ALL ERROR on '1H': ValueError
这个框架确实抓到了真正的问题。在 30m1h 上,A 团队的正则表达式拒绝了乱序输入,而 B 和 C 团队接受了它。在 1h2h 上,B 团队的扫描器默默地把两个小时相加,而 C 团队的重复检测抛出了错误。这正是差分测试应该发现的 bug。
但看看 0h。规范说的是”正整数”。零不是正数。三个实现都接受了它,因为没有一个团队为一个听起来显而易见、却未被强制执行的要求编写校验逻辑。它们达成了一致,所以测试通过了。这就是一个藏在眼皮底下的共模故障。
1H 也是同样的情况。三个实现都拒绝了它,因为规范里用的是小写单位。但如果规范的本意是大小写不敏感匹配,那么每个实现都是错的,而且错得一致。
如何让差分测试不那么容易出错
没有形式化证明,你无法彻底消除共模故障。但你可以降低它们发生的概率。
多样化的是实现策略,而不仅仅是人员。如果每个团队都使用同一本教科书上的相同算法,你建立的不是多样性,而是延迟。强制一个团队使用状态机,另一个使用解析器生成器,第三个使用递归。不同的算法会在不同的输入上失效。
使用不同的编程语言。共享的标准库 bug 是典型的共模故障。如果每个实现都使用相同的 JSON 解析器或相同的浮点数数学库,它们就会共享其中的 bug。
引入对抗性预言机(adversarial oracle)。指派专人去寻找规范存在歧义的输入。他们的任务就是让实现之间产生分歧。能让它们分裂的输入,是你能写出的最有价值的测试。
积极进行模糊测试(fuzz)。在少数几个手工挑选的示例上达成一致是薄弱的证据。在一百万个随机生成的输入上达成一致则更有说服力。模糊测试能发现输入空间中没有任何团队考虑到的角落。
测试规范本身。为”正整数”这类要求编写显式的负面测试,并检查至少有一个实现会拒绝它们。如果三个实现都接受了一个无效输入,那么需要收紧的是你的规范,而不是代码。
什么时候差分测试已经足够
差分测试不是形式化验证的替代品。它是一个过滤器。它能在你投入精力进行证明之前,以低廉的成本及早发现实现层面的 bug。
问题不在于你是否能信任它,而在于你能信任它做什么。你可以信任它能发现分歧,但你不能信任它在规范糟糕的情况下还能发现普遍一致的错误。
如果你在构建安全关键型软件,差分测试只是一个初步步骤。先运行它,修复分歧,然后将规范提交给模型检查器(model checker)或证明助手(proof assistant)。如果你在构建 Web 服务,差分测试可能就足以让你对一个功能分支充满信心。证明的力度与风险成正比。
如果你今天正在运行一个 N 版本测试套件,增加一个测试用例。找一个规范没有明确定义的输入。把它扔进你的实现里跑一遍。如果它们全部一致,你找到的不是一个好测试,而是一个规范漏洞。
真正致命的 bug,不是你的实现出现分歧的那些,而是它们因错误的原因达成一致的那些。
常见问题
什么是差分测试?
差分测试是一种技术:针对相同的输入,执行多个基于同一规范的相互独立的实现,然后比较它们的输出。分歧能够揭示 bug,而无需为每个输入预先准备真理来源。
软件中的共模故障是什么?
共模故障(common-mode failure)是指多个独立组件因相同的根本原因而在同一输入上失效。在 N 版本编程中,这通常发生在规范存在歧义,而每个团队都以相同方式解决歧义的情况下。
差分测试与基于属性的测试有何不同?
基于属性的测试(property-based testing)检查输出是否满足通用规则,例如”排序不会改变列表长度”。差分测试检查多个实现是否产生相同的输出。这两种技术互为补充。基于属性的测试发现逻辑错误,差分测试发现不一致性。
我可以用 LLM 生成多样化的实现用于差分测试吗?
可以,但要小心。在重叠语料库上训练的模型往往会生成具有相关失效模式的代码。近期研究表明,AI 生成组件的共同错误率(co-error rate)在 15% 到 30% 之间。如果你使用 LLM,要在提示词中要求真正不同的算法和语言。一组语义完全相同的改写,不能算多样性。