Metamorphic testing 在 GCC 和 LLVM 中发现了 147 个已确认的 bug,在汽车 OEM 使用的商用 ADAS 模拟器中发现了缺陷,并在一辆自动驾驶汽车撞死行人前八天,在其感知系统中发现了一个致命缺陷。这项技术听起来很学术,但这些 bug 并非理论虚构。

问题在于 oracle problem。对于许多程序,你可以运行输入,但无法独立验证输出是否正确。在一个拥有 10,000 个节点的路网中,最短路径究竟是什么?某个 编译器优化 是否保留了语义?某个 ML 模型的分类结果真的正确吗?你不知道。传统的 unit testing 在这里会崩溃,因为当你根本不知道 expected 应该是什么时,你无法写出 assertEquals(expected, actual)

Metamorphic testing 通过完全不检查输出来绕过这个问题。它检查的是输出之间的关系。

什么是 metamorphic testing?

Metamorphic testing 是一种技术:你将一个输入转换成相关的输入,让两者都通过你的程序运行,然后断言两个输出遵守某个已知的数学或逻辑关系。这种关系称为 metamorphic relation。

如果你的程序计算一个数字列表的平均值,你不需要知道 [4.2, 1.7, 9.3, 2.1] 的确切平均值就能测试它。你只需要知道,打乱列表顺序应该产生相同的结果,或者将每个元素翻倍应该使平均值翻倍。这些就是 metamorphic relations。

第一个输入是 source test case。转换后的输入是 follow-up test case。Oracle 就是关系本身。

下面是一个具体的 Python 示例:

import random

def compute_average(numbers):
    """Returns the arithmetic mean of a list of numbers."""
    if not numbers:
        raise ValueError("empty list")
    return sum(numbers) / len(numbers)

def test_average_permutation_invariant():
    """MR-1: Shuffling the input should not change the average."""
    source = [4.2, 1.7, 9.3, 2.1, 5.6]
    follow_up = source.copy()
    random.shuffle(follow_up)

    source_out = compute_average(source)
    follow_up_out = compute_average(follow_up)

    assert source_out == follow_up_out, (
        f"Permutation MR failed: {source_out} != {follow_up_out}"
    )

def test_average_scaling():
    """MR-2: Doubling every element should double the average."""
    source = [3.0, 6.0, 9.0]
    follow_up = [x * 2 for x in source]

    source_out = compute_average(source)
    follow_up_out = compute_average(follow_up)

    assert follow_up_out == source_out * 2, (
        f"Scaling MR failed: {follow_up_out} != {source_out * 2}"
    )

def test_average_inclusion():
    """MR-3: Appending the average itself should not decrease the average."""
    source = [10.0, 20.0, 30.0]
    source_out = compute_average(source)
    follow_up = source + [source_out]
    follow_up_out = compute_average(follow_up)

    assert follow_up_out == source_out, (
        f"Inclusion MR failed: {follow_up_out} != {source_out}"
    )

if __name__ == "__main__":
    test_average_permutation_invariant()
    test_average_scaling()
    test_average_inclusion()
    print("All metamorphic relations passed.")

如果这些关系中的任何一个失败了,你就发现了一个 bug,而无需手动计算预期的平均值。这就是核心思想。

在生产系统中发现的真正 bug

这项技术并非纯理论。以下是记录在案的 metamorphic testing 在生产软件中发现真实 bug 的案例。

GCC 和 LLVM 中的 147 个 bug

研究人员将 metamorphic testing 应用于 C 编译器优化 pipeline,在 GCC 和 LLVM 中发现了 147 个已确认的 bug。这些不是玩具程序。它们是真正的 miscompilation bug:一个正确的 C 程序在通过 优化编译器 运行时,产生了错误的机器码。其中一些 bug 已经存在多年。Metamorphic relations 很简单:如果你手动 inline 一个函数,优化后的输出应该与原始版本行为一致;如果你置换独立的语句,结果不应该改变。编译器开发者确认并修复了这些 bug。

Google 的 Vulkan shader 编译器

Google 的 GraphicsFuzz 团队将随机化的 metamorphic testing 投入 Khronos Vulkan Conformance Test Suite 的生产使用。他们生成随机的 fragment shader,应用保留语义的转换(例如将表达式包装在 identity function 中或添加 dead code),并在不同的编译器和 GPU 之间比较渲染图像。当两个本应等价的 shader 产生不同的像素时,他们发现了一个编译器 bug。该团队构建了一个名为 gfauto 的完整 pipeline 来精简、去重和报告这些案例。他们在转换、优化和验证 Vulkan shader 的工具生态系统中发现了 bug,其中包括已交付给最终用户的生产驱动。

汽车 OEM 使用的 ADAS 模拟器

一个团队测试了三个主流的 ADAS 仿真平台:Simulink、CarMaker 和 51Sim-One Cloud,重点测试它们的 Lane Keeping Assist Systems。普通测试用例在三个平台上都通过了。完全没有问题。然后,该团队应用了几何 metamorphic relations:水平镜像道路场景、旋转车辆位置、对车道标记应用仿射变换。输出应该以可预测的方式变化。但它们没有。三个平台都暴露了 bug。MathWorks 和 IPG Automotive 后来确认了这些问题。这些平台正是用于在软件上车前进行验证的。

同一类别的 self-driving car 缺陷

在一个最令人警醒的案例中,研究人员将 metamorphic testing 应用于自动驾驶汽车的目标检测系统,在感知 pipeline 中发现了一个 bug。该系统在特定的转换输入下未能正确分类行人。他们报告了这个问题。他们发现的 bug 属于同一类缺陷,这类缺陷已被认为与致命的 self-driving car 行人碰撞有关。

权衡:relations 是领域特定的

Metamorphic testing 很强大,但并非没有代价。难点在于识别好的 metamorphic relations。一个差的 relation 会给你虚假的信心。太弱的 relation 抓不到 bug。太强的 relation 会因为 floating-point noise 或 non-determinism 而在正确行为上失败。

设计 relations 需要领域知识。对于 shortest-path 算法,好的 relations 包括:在无向图中,A->B 的路径成本应等于 B->A;给每条边的权重加上一个常数,总路径成本应增加该常数乘以边数。对于 sorting 算法:将已排序的列表反转后再排序,应得到原始已排序列表的反转;输出中的每个元素都应以相同的频率出现在输入中。

你无法在不相关的系统中复用相同的 relations。这就是代价。

Floating-point 算术是另一个陷阱。许多 relations 假设精确相等,但在 IEEE 754 中 0.1 + 0.2 != 0.3。你需要基于容差的比较,而选择正确的容差本身就是一个问题。太紧会产生 false positives。太松会漏掉真正的 bug。

如何将 metamorphic testing 加入你的 codebase

你不需要框架。你需要纪律。

从你的 codebase 中没有 oracle 的函数开始。ML inference、optimization 算法、几何计算、统计聚合和仿真代码都是候选对象。对每一个函数,问自己:如果我以特定、可预测的方式改变输入,输出必须满足什么条件?

每个测试函数写一个 metamorphic relation。命名要清晰。在 CI 中与你 unit tests 一起运行。当某个 relation 失败时,像对待其他任何测试失败一样处理它。

下面是一个稍微更真实的测试 pathfinding 函数的例子:

import math

def shortest_path_cost(graph, start, end):
    """Returns the cost of the shortest path. Assume implemented."""
    pass

def test_shortest_path_undirected_symmetry():
    """MR: In an undirected graph, path cost A->B equals B->A."""
    graph = {
        'A': [('B', 3.0), ('C', 1.0)],
        'B': [('A', 3.0), ('C', 1.0)],
        'C': [('A', 1.0), ('B', 1.0)],
    }
    ab = shortest_path_cost(graph, 'A', 'B')
    ba = shortest_path_cost(graph, 'B', 'A')
    assert math.isclose(ab, ba, rel_tol=1e-9), f"Symmetry failed: {ab} != {ba}"

def test_shortest_path_subpath():
    """MR: The shortest path cost cannot exceed any specific path's cost."""
    graph = {
        'A': [('B', 2.0), ('C', 10.0)],
        'B': [('C', 2.0)],
        'C': [],
    }
    cost = shortest_path_cost(graph, 'A', 'C')
    assert cost <= 10.0, f"Subpath MR failed: {cost} > 10.0"
    assert math.isclose(cost, 4.0, rel_tol=1e-9), f"Expected 4.0, got {cost}"

你不是在测试算法本身。你是在测试你对它的实现。

常见问题

Metamorphic testing 会取代 unit tests 吗?

不会。它是对 unit tests 的补充。当你知道预期输出时,使用 unit tests;当你不知道时,使用 metamorphic tests。

我能把它用于 ML 模型吗?

可以,而且这是最活跃的研究领域之一。像”旋转一张猫的图片,它仍应被分类为猫”这样的 relations 就是 metamorphic relations。研究人员使用这种方法发现了模型可靠性问题和公平性差距。

我怎么知道我的 metamorphic relation 是正确的?

你无法证明它。你根据规范或领域的数学性质来论证。如果你的 relation 本身有 bug,你会得到 false positives。从显而易见的性质开始,随着信心增加再添加更多。

Flaky tests 怎么办?

非确定性系统(概率算法、并发代码、带超时机制的系统)会让 metamorphic testing 更难。你可能需要运行多次试验,或使用统计关系而非精确相等。

从一个 relation 开始

使用它不需要博士学位。在你的系统中选一个函数——你目前因为验证输出太难而跳过测试的那个。写一个 metamorphic relation。运行它。如果你想深入,GraphicsFuzz 团队的 gfauto 工具是开源的,Segura 等人的 ACM 综述整理了数十个领域的 relations。

这项技术发现了 147 个编译器 bug,确认了汽车仿真平台中的缺陷,并在自动驾驶汽车上路前暴露了一类感知故障。这些 bug 是真实的。唯一的问题是你是否在寻找它们。