基于属性的测试:它到底是什么?

2023-04-24   出处: substack.com  作/译者:KURT/lukeaxu

这是一系列关于基于属性的测试的第一篇介绍性文章。本文将解释什么是基于属性的测试,以及典型的基于属性的测试是什么样子的。本系列的其余部分将深入探讨基于属性的测试库的实现方式。

系列文章:

  1. 基于属性的测试:它到底是什么? ← 你在这里

  2. Vintage QuickCheck 的基本要素

  3. 收缩第一步

  4. 统一随机生成和收缩

  5. 收缩选择,收缩值

  6. 随机到底

基于属性的测试是由 Koen Claessen 和 John Hughes 于 2000 年通过 Haskell 库 QuickCheck 引入的。它在相对短的时间内变得流行起来 - 如今几乎所有的语言和平台都有一些基于属性的测试库可用。

但是基于属性的测试到底是什么,更重要的是你可以用它来做什么呢?

在传统的单元测试中,测试由一个示例输入组成,该输入被提供给被测试系统,然后断言输出是否符合我们的期望。在基于属性的测试中,测试将输入和被测试系统的输出相关联,通过断言在大量自动生成的输入上是否满足某些通用属性来进行。

例如,让我们想象一个函数 sort_by_age,它以 Person 对象的列表作为参数,这些对象具有各种字段(如姓名),并按年龄对它们进行排序。我们问题的一个单元测试可能是:

persons = [
    Person("Max",12), 
    Person("Nemo",22), 
    Person("Eloise",15)
]

actual = sort_by_age(persons)

expected = [
    Person("Max",12),
    Person("Eloise",15),
    Person("Nemo",22)
]
assert actual == expected

为了编写一个基于属性的测试,考虑如何编写一个函数 is_valid(persons_in, persons_out) -> bool,该函数给定 sort_by_age 的输入和输出,返回该输入输出对是否有效。

很有可能你已经想出了一些看起来微不足道的检查。例如,is_valid 可以检查两个列表是否具有相等的长度——毕竟,对列表进行排序不应该改变其长度:

def is_valid_length(persons_in, persons_out):
    assert len(persons_in) == len(persons_out)

另一个检查可能是检查 persons_out 列表是否确实按年龄排序:

def is_valid_sorted(persons_in, persons_out):
    assert all(
        p[i].age <= p[i + 1].age
        for i in range(len(persons_out)-1)
    )

最后,我们可以检查排序后Person对象的值是否发生了变化 - 为了简洁起见,我们只检查他们的姓名是否发生了变化:

def is_valid_unchanged(persons_in, persons_out):
    assert { p.name for p in persons_in } == { p.name for p in persons_out }

如果你想到了其中一个或多个,那么恭喜你 - 你刚刚想出了一个属性,现在你是一个完全合格的基于属性的测试者!

还不相信?给定一个is_valid方法,为sort_by_age编写一个基于属性的测试很容易:

for _ in range(ONE_GAZILLION):
    persons_in = generate_persons()
    persons_out = sort_by_age(persons_in)
    is_valid_length(persons_in, persons_out)
    is_valid_sorted(persons_in, persons_out)
    is_valid_unchanged(persons_in, persons_out)

与单元测试(也许更好地描述为基于示例的测试)相比,我们需要想出一个示例输入和一个期望输出,而在这里,某个东西(我们很快会回到这个问题)提供输入,我们测试输入和输出对是否代表被测试系统的有效行为。

由于我们不再需要手动想出示例输入,我们可以生成任意数量的测试用例,通常是数百个。对于需要探索大量输入组合的测试,我们甚至可以让测试运行一整夜,并在早上检查是否在数百万或数十亿个输入中发现了任何错误。假设生成的输入是合理的,这可以极大地增强我们对实现正确性的信心。

这种方法的威力在于结合多个简单的属性。很容易想出一个显然错误的 sort_by_age 实现,它通过其中一个 is_valid 属性 - 例如,为了通过 is_valid_unchanged,sort_by_age 只需要原样返回输入列表。但是,要想出一个通过所有三个属性的实现,而且不做我们所期望的事情,就要难得多。

如果你在几段文字前考虑编写 is_valid,你希望体验到编写基于属性的测试的思考过程与编写单元测试的思考过程非常不同。据说,这是许多人第一次接触基于属性的测试时遇到的困难之一 - “我很难想出属性”是一个经常听到的抱怨。

然而,好处在于我们确定了函数行为的各个方面,并在测试代码中清晰、分离和明确地显示了它们。在这个过程中,我通常会学到一些关于实现的新东西。对于sort_by_age,也许你会想知道排序是否应该是稳定的。根据上面的属性,它不必是稳定的,但如果我们想要,我们可以很容易地添加这个要求。这样的问题在传统的单元测试中很可能会被忽略。在基于示例的测试中,属性都是隐含的,任何阅读测试的人都需要从示例中推断出它们。基于属性的测试就像是一个轻量级的规范,直接用我们选择的编程语言表达出来。

话虽如此,具体的例子是有帮助的,所以根据我的经验,最好的测试套件是将基于示例和基于属性的测试混合在一起。就像好的文档有教程和操作部分(具体、具体的“例子”),但也有一些背景和参考材料(抽象、通用的“属性”)一样。

那么,魔法般的generate_persons()是如何工作的呢?我们怎么能确定它实际上生成了合理的值,而不是一堆空列表呢?

这正是基于属性的测试库(如QuickCheck及其许多后继版本)发挥作用的地方。它们提供了函数,允许您为您的域类型(如Person)编写值生成器。它们还有许多基本的生成器,用于原始值、列表、字典等,可以组合成自定义类型的生成器。我们将在系列的其余部分中更详细地介绍该API的结构。为了给您一个想法,这里是一个在Python的真正基于属性的测试库Hypothesis中编写的生成器:

# hypothesis将生成器称为“策略”:
from hypothesis import strategies as st

composite_generator = st.tuples(st.booleans(), st.text())

这个复合生成器(为了保持一致性,我将继续称其为生成器,尽管Hypothesis有不同的命名约定)将基础库中的三个生成器混合在一起,创建一个生成器,可以生成布尔值和字符串的元组。

此外,库还提供了运行测试和指定停止条件的方法,获取生成值的一些统计信息,允许您重放特定的失败测试以及其他各种好处。

以下是Hypothesis中使用上述生成器的一个(微不足道的)测试示例:

@given(st.tuples(st.booleans(), st.text()))
def test_tuples(t):
    assert len(t) == 2
    assert isinstance(t[0], bool)
    assert isinstance(t[1], str)

希望这能让您了解基于属性的测试的样子。毫无疑问,您会想知道这些值是如何生成的:

  1. 最广为人知的策略是伪随机生成——这是QuickCheck开创的方法。其思想是随机生成值,使分布偏向于通常会导致错误的值,例如对于整数,0、-1、1等。这种策略在实践中非常有效,而且相对简单易行。缺点是它可能会生成非常大的值,当使用大值发现错误时,通常只有值的一小部分或方面会触发错误。我们不知道这个方面是什么,这使得理解或调试故障变得困难。为了帮助解决这个问题,所有现代基于属性的测试库都会尝试在测试失败时“缩小”值——也就是说,当测试失败时,它们会尝试找到更小的值(例如更小的整数、更短的字符串),这些值仍然无法通过测试。这个过程类似于运行git bisect来识别导致某些错误的提交。

  2. 另一种策略是穷举生成。在这种方法中,以某种明确定义的顺序生成某种类型的所有可能值——通常是从“小”到“大”的值,并且有一些上限,因为除了布尔值,大多数类型的值的数量是(可数)无限的。例如,在“之”字形顺序下尝试所有介于-20和20之间的整数0,1,-1,2,-2,…. Haskell的SmallCheck和Scala的SciFe就是这样做的,但这种方法并不是很广为人知。这很遗憾,因为随机和穷举生成是互补的——如果将生成值视为探索某个大空间以查找失败的测试,则随机生成是一种偶然的探索类型,而穷举生成则是勤奋地映射某个区域中的所有路径。

  3. 最后一个策略是我所谓的优化引导生成,其中算法试图生成值,使某些度量最大化。在我所知道的方法中,这个度量通常是代码覆盖率,并通过智能代码分析实现。实际上,被测试的代码是符号执行的,求解器计算输入值,使分支走向一边或另一边。.NET的Pex就是其中之一 - 不幸的是,它已经不再维护,现在可能已经无法使用了。我不确定这种复杂性是否值得 - 显然,生成策略的计算成本要高得多,更不用说这些库的开发和维护成本了。这可能解释了为什么我大多数看到它们来自学术界或研究领域。

为了总结和强调我们所学到的知识,让我们看看属性驱动测试之父QuickCheck是如何描述自己的:

QuickCheck是一种自动测试Haskell程序的工具。程序员提供程序的规范,以函数应满足的属性的形式,然后QuickCheck在大量随机生成的情况下测试这些属性是否成立。规范是用Haskell表达的,使用QuickCheck库中定义的组合子(combinators)。QuickCheck提供组合子来定义属性、观察测试数据的分布和定义测试数据生成器。

以下是一些备注:

  • “QuickCheck[…]自动测试[…]程序”。自动是指自动生成示例输入。如上所述,用户仍然需要编写测试。

  • “QuickCheck提供组合子[…]”。这里组合子的术语有一个相当具体的含义,我们将在后续的文章中详细介绍。简而言之,组合子是可以任意组合的普通函数或方法。

这就是关于基于属性的测试系列的介绍。在下一篇文章中,我将介绍一个基于伪随机生成的基本实现属性测试库,接下来的文章中,我们将深入探讨缩小 - 随机属性测试的许多创新都围绕着缩小策略展开。

下次见!


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
239° /2399 人阅读/0 条评论 发表评论

登录 后发表评论