你为定价引擎写了十二条 metamorphic relations。每个测试都通过了,你对自己的覆盖率感觉良好。

然后有客户报告批量折扣算反了。你检查自己的关系套件,没有一个测试失败。你有加法一致性、单调性和幂等性的关系,却没有一个能捕获折扣乘数中的符号错误。

这是 metamorphic testing 的肮脏秘密:有关系不等于有有用的关系。一个弱的 metamorphic relation 比没有测试还糟,因为它会在代码实际错误时让你相信代码是正确的。

什么让一条关系”好”?

一个好的 metamorphic relation 具有很高的故障检测能力。它能捕获程序员实际会写的真实 bug,其他的只是开销。

经典的例子是用差一 bug 测试均值函数:

def buggy_mean(values):
    """Compute the arithmetic mean."""
    return sum(values) / (len(values) - 1)  # bug: off-by-one in denominator

如果你熟悉样本方差公式,这看起来似乎合理。但它是错的。下面是人们通常为均值函数写的四种关系,以及每一种实际能捕获什么:

  1. 有界性:均值介于最小值和最大值之间。弱。对于有 bug 的均值函数,大多数输入仍然满足这一点。

  2. 常数上的幂等性mean([c] * n) == c。中等。它能捕获常数列表上的 bug,但随机数据很少触发失败。

  3. 平移不变性mean([x + c for x in values]) == mean(values) + c。强。有 bug 的分母几乎对每个非空输入都会破坏这一点。

  4. 缩放mean([x * k for x in values]) == mean(values) * k。强。原因相同。差一错误在缩放中恰好无法在任何一个有意义的案例中存活。

如果你的测试套件只检查了有界性和常数幂等性,差一错误就会顺利进入生产环境。你有了 metamorphic tests,却没有 bug 检测能力。

强关系 vs. 弱关系

强关系和弱关系的区别不在于听起来多聪明,而在于它能消除多少故障类别。

弱关系检查的性质,大多数错误的实现碰巧也能满足。有界性就是完美例子。大多数算术 bug 保持了有界性,因为加法和乘法不会自发产生超出输入范围的值。对错误代码也能通过的关系只是演戏。

强关系编码了错误实现会违反的结构性约束。平移不变性强,是因为它通过精确的等式将输入转换与输出转换绑定在一起,没有任何回旋余地。

你可以正式地衡量这一点。在 metamorphic testing 研究中,关系的 subsumption 意味着关系 A 能检测到关系 B 检测到的所有故障,甚至更多。如果 A subsumes B,那么 B 就是冗余的。你应该保留 A,删除 B。

在实践中,你不需要形式化证明。你需要的是直觉:如果你故意引入一个合理的 bug 后,某个关系仍然通过,那它就是弱的。扔掉它。

好的关系覆盖不同的故障域

一个强关系不够。单个关系只能捕获一类错误。真实程序包含多种独立的 bug 类型,你的关系集需要覆盖它们。

考虑一个排序函数。下面是按捕获内容排序的关系:

排列:输出包含与输入完全相同的元素。能捕获丢失/重复 bug,但遗漏排序 bug。

顺序:输出是非递减的。能捕获比较 bug,但遗漏排列 bug。

幂等性sort(sort(x)) == sort(x)。只能捕获真正会破坏有序性的实现。几乎没用。

稳定性:如果你将每个元素与其原始索引配对,相等键保持输入顺序。能捕获使用 >= 而非 > 的比较运算符。

子结构:先排序前缀再排序完整列表,两者在前缀顺序上应该一致。能捕获提前终止 bug。

只有排列和幂等性的测试套件会漏掉一个总是返回 [1, 2, 3] 的排序。有排列和顺序的套件能捕获那个 bug。加上稳定性,你还能捕获不稳定的排序。

重点不是尽可能收集多的关系。重点是覆盖独立的故障模式。两个捕获同一个 bug 的关系,不如一个捕获不同 bug 的关系。

权衡:强关系更难找

团队写弱关系是有原因的。强关系需要领域知识。你需要足够理解问题的数学结构,才能编码出一个非显而易见的 invariant。

对于均值函数,有统计学背景的人一眼就能看出平移不变性。对于粒子模拟,等价的关系可能需要你知道哈密顿动力学保持相空间体积。不是每个团队手头都有这种专业知识。

另一个代价是调试。当强关系失败时,违反告诉你某个结构性性质被破坏了,但 bug 可能存在于导出该性质的推理链中的任何地方。像”输出长度等于输入长度”这样的弱关系只有一种失败方式。而像”移位信号的傅里叶变换获得线性相位项”这样的强关系有一百种失败方式,找出哪一种才是你的 bug 需要更长时间。

这是核心张力。弱关系容易写、容易调试,但基本没用。强关系难写、难调试,但确实能找到 bug。没有免费的午餐。

如何评估 metamorphic relation

在把一条关系加入测试套件之前,先做三项检查:

故意引入 bug 测试。

在你的实现中引入一个合理的 bug。这条关系会失败吗?如果不会,那这条关系就没有发挥作用。试试符号错误、差一错误、参数交换、缺失边界条件。这些都是在生产环境中真实发生的 bug,你的关系应该能捕获它们。

独立性测试。

看看你现有的关系。其中有没有能捕获同一个 bug 的?如果有,这条新关系就是冗余的。冗余不是安全,是没有边际效益的维护负担。

可证伪性测试。

你能想象一个合理的错误实现仍然满足这条关系吗?如果你能在三十秒内画出一个,那这条关系就太弱了。好的关系应该感觉像一个紧约束,而不是模糊的建议。

下面是均值函数的代码示例:

import random

def mean(values):
    return sum(values) / len(values)

def test_translation_invariance():
    values = [random.uniform(-100, 100) for _ in range(20)]
    c = 5.5
    shifted = [x + c for x in values]
    assert mean(shifted) == mean(values) + c

def test_scaling():
    values = [random.uniform(-50, 50) for _ in range(20)]
    k = 3.0
    scaled = [x * k for x in values]
    assert mean(scaled) == mean(values) * k

现在引入差一 bug:把 len(values) 改成 len(values) - 1。运行两个测试。平移不变性立即失败,缩放立即失败,而有界性很可能通过。

这就是一条配得上留在套件里的关系和一条只是占行数的关系之间的区别。

从故障类别出发,而不是从性质出发

大多数团队犯的错误是先头脑风暴性质。他们问”这个函数有哪些 invariants?“这会产生弱关系,因为 invariants 容易陈述却难以违反。

相反,应该从故障类别出发。问”一个疲惫的程序员会在这个函数里写什么 bug?“然后找到能捕获这些 bug 的关系。

对于几何距离函数,可能的 bug 是符号错误、单位混淆和维度不匹配。检查距离非负的关系能捕获符号错误。检查坐标变换下缩放的关系能捕获单位混淆。检查三角不等式的关系能捕获维度错误。

如果你说不出一条关系捕获的是什么 bug,你就不需要那条关系。

关系是稀缺资源,要明智地使用

Metamorphic testing 不是为了覆盖率指标,而是为了信心。一条能捕获真实 bug 的强关系,比二十条对错误代码也能通过的弱关系更有价值。

审计你现有的 metamorphic tests。引入一个 bug,看看什么失败。删掉没失败的。然后为每个你真正担心的故障类别添加一条关系。这才是一个物有所值的测试套件。