为什么 Property Testing 能发现单元测试发现不了的 Bug

我原本计划这个时事通讯不做任何编辑,直接分享我的想法。现在我又有了一个新的想法,所以就开始写了。我想回应一下这个讨论:

但现在的孩子们认为随机生成输入(property-based testing)比找到最具代表性的输入更酷。(参见 Hamlet&Taylor, "Partition testing does not inspire confidence", 1990, https://t.co/rNLugvGxob) — Brian Marick (@marick@mstdn.social) (@marick) March 27, 2021

(标准免责声明,不要煽动攻击。这是一个文明的讨论,而且 Brian 是我的朋友。但即使情况并非如此,煽动攻击也是 chillul Hashem)

对于那些刚加入的人,Property-Based Testing (PBT) 指的是,你不在测试中给出特定的输入-输出,而是描述执行应该具有的通用属性。然后你在随机值上尝试它。一个简单的例子是测试加法是否满足交换律:

def add(a, b):
  return a + b
@given(integers(), integers())
def test_add_is_commutative(a, b):
  assert add(a,b) == add(b,a)

(我使用 pytesthypothesis 来完成所有操作。)

你可以在这里阅读更深入的关于 PBT 的讨论。PBT 非常强大,因为它允许你测试更广泛的输入范围,但你必须学习如何提出属性以及如何执行复杂的策略(输入生成器),这两者都是技能。

无论如何,Brian 在推文中的论点 大致 如下:

  1. 你通过测试发现的大多数错误要么是整个输入“分区”的问题,要么是“边界”输入的问题,例如 INT_MIN
  2. 对于分区错误,你可以选择任何随机问题并找到它,在这种情况下,你还不如只为该输入编写一个单元测试。
  3. 对于边界错误,这些错误源于问题的规范,并且为了处理这些错误,无论你计划如何测试,都必须认真思考问题以及哪些是意想不到的情况。
  4. 在这种情况下,PBT 无法提供优于手动单元测试的充分好处。

我不认为这完全是他的推理思路,但我认为它足够接近,我对这个版本的响应应该与对他预期论点的响应兼容。PBT 优于手动单元测试的价值在于,即使在中等复杂度的问题中,边界条件和边缘情况也会呈组合式爆炸。

测试的几何学

考虑一个带有一个整数输入的函数:

def f(x: int):
 ...

我们可以将该函数想象成一个 2D 图,就像你在高中看到的那种。这意味着你可以看到输入的“空间”元素:它是一条一维线,描述了整个输入空间。如果我们手动编写所有测试,我们会尝试一些“好”输入,然后是一些病态的情况:

对于手动测试来说,这是在可行范围内的。如果在问题的规范中存在任何边缘情况,你可能会看到它们。

如果我们有两个整数输入会怎样?

def g(x: int, y: int):
 ...

现在输入空间是二维的。这立即平方了我们的病态情况:我们不仅必须测试 xy 的边缘情况,而且我们还必须专门测试它们都是病态的所有 组合!但除此之外,我们还有源于空间几何的新边缘情况:

同样,通过一些聪明才智和对规范的研究,你可能可以确定实际存在问题的边缘并手动测试它们。此外,你可以编写一次命中多个边缘的测试,例如测试 (1, 1)。但即便如此,你也可以看到,拥有两个变量的额外 结构 会导致更多的边缘情况。二维平面比一维线复杂得多。三维空间比二维平面更复杂——我们期望另一组边缘情况出现在三个变量的交互中。

我把“输入空间”说得像是一个几何事物,但这是一个糟糕的类比。对于字符串输入、数字列表 1 或嵌套字典,没有几何类比。这削弱了我们的测试能力,因为我们无法通过思考几何来假设存在问题的边缘。更多的边缘情况变得 不直观,并且更有可能有人会错过它们。随着我们越来越难以考虑系统的 所有 边缘,随机测试变得越来越有用。

(对此的一个常见的“直觉”是,对于一个 10 维超立方体,“99.75% 的体积都在角上”[https://www.johndcook.com/blog/2016/07/11/formal-methods-let-you-explore-the-corners/]。但我已经将“几何的有趣子结构”作为边缘情况的例子包括在内,而那篇文章是基于边缘情况是“远离中心”的值来建立直觉。而且我已经说过几何类比会崩溃。该链接是一篇好文章,但连续阅读我们的两篇文章可能不是一个好主意。)

大多数 PBT 示例都很糟糕

在随后的讨论中,Brian 表达了对 PBT 示例 "使用数字和数组" 的厌恶。我在那里同意他的看法。我的 PBT 示例“加法满足交换律”以及臭名昭著的“将列表反转两次会得到相同的列表”都是令人翻白眼的陈词滥调。它们之所以受欢迎,是因为它们是具有简单输入策略的明显属性。你不需要 擅长 PBT 就可以向其他人解释这些示例。

每次有人使用将列表反转两次来演示 property-based testing 时,我都会喝一杯。不,这不是一个饮酒游戏,我只是被糟糕的例子逼得喝酒。 — David R. MacIver (@DRMacIver) June 17, 2019

但这种简单性也使它们成为糟糕的例子。如果没有复杂的输入空间,就不会出现边缘情况的爆炸式增长,这会最大限度地减少 PBT 的实际好处。2 真正的优势在于你拥有复杂的输入空间时。不幸的是,你需要擅长 PBT 才能编写复杂的输入策略。我在这里写了一些关于它的内容,但那仅适用于 Hypothesis。策略 API 在 PBT 库之间有所不同,这意味着我们无法以更通用的方式编写关于技能的内容。

(相比之下,提出好的属性 可以推广的,这就是为什么有更多关于属性的帖子比关于策略的帖子多的原因。)

所有这些都意味着,人们用来演示 PBT 好处的大多数示例...... 实际上并没有做到。我们最好编写使用字符串和字典或使用两个以上输入的示例。在这种问题中,你 认为 你已经研究了规范并编写了好的单元测试,但仍然错过了边缘情况。我对这些示例可能是什么样子有一些想法,但它们现在仍然只是笔记本中的草图。

  1. 好吧,当然,它有点像一个无限维向量空间,好吧赢了
  2. 我刚刚说过列表基本上是“无限维度”的,你 会认为 这会使“反转列表”很有趣,但反转实际上并没有对输入空间做任何事情。它“只有一个分区”。我相信有一种方法可以将其与类型签名联系起来,并认为 [a] -> [a] 本质上是抗分区的。但我没有足够的类型理论知识来做到这一点。