差分测试到底能给你带来什么

你可以在没有形式化证明的情况下信任差分测试,但前提是你必须清楚它究竟会在哪里失效。

这个弱点叫做共模故障(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,要在提示词中要求真正不同的算法和语言。一组语义完全相同的改写,不能算多样性。