一小时内从 1 个测试用例到 10,000 个测试用例:基于属性的测试初学者指南

2025-06-09

一小时内从 1 个测试用例到 10,000 个测试用例:基于属性的测试初学者指南

本指南由Fredrik Fornwall共同撰写

测试软件需要时间……非常耗时。编写测试时,您常常需要手动重现所有可能的事件序列。但是,如果您想同时测试数百个(数千个甚至数百万个)用例,该怎么办呢?我们有一个解决方案:基于属性的测试

也许你以前写过单元测试,但这是你第一次听说基于属性的测试。又或许你听说过这个术语,但仍然不太明白它的含义。无论如何,我们都能帮你搞定。

本指南将涵盖基于属性的测试的基础知识。我们将通过实际示例指导您构建测试。最后,您将学习如何使用基于属性的测试来查找代码中的错误,以及有哪些现有的测试库。

本指南包含的内容

⚠️先决条件

  • 对单元测试的总体了解。
  • (可选)如果您想在自己的 IDE 中继续操作,则需要Python 3+ *。

* 本指南将使用 Python 作为代码示例,但其概念并不局限于任何特定的编程语言。因此,即使您不了解 Python,我们也鼓励您继续阅读。

💻参考资料
我们创建了一个GitHub 仓库来配合本指南。所有精选的代码示例都以单元测试的形式存在,并包含如何执行它们的说明。

基于示例的传统单元测试

软件测试通常使用基于示例的测试。这意味着您需要测试给定参数是否获得已知的返回值。这个返回值之所以已知,是因为您提供了该精确值作为样本。因此,当您运行函数或测试系统时,它会根据该样本返回值断言实际结果。

我们来看一个例子。假设你想编写一个名为 的函数sort_this_list。该函数将接受一个列表作为参数,并返回按升序排列的列表。为此,你需要使用 Python 内置sorted函数。

它可能看起来如下所示:

# test_sorted_list.py
def sort_this_list(input_list):
    sorted_list = sorted(input_list)
    return sorted_list

现在您已经有了sort_this_list功能,让我们来测试它。

要使用基于示例的测试来测试这一点,您需要(手动)为测试函数提供您知道的返回值True。例如,列表[5, 3, 1, 4, 2]应该[1, 2, 3, 4, 5]在排序后返回。

# test_sorted_list.py
def test_sort_this_list():
    assert sort_this_list([5, 3, 1, 4, 2]) == [1, 2, 3, 4, 5] # True
    assert sort_this_list(['a', 'd', 'c', 'e', 'b']) == ['a', 'b', 'c', 'd', 'e'] # True

这样,你就通过了基于示例的测试🎉

基于示例的测试的局限性

虽然基于示例的测试在很多情况下效果很好,并且(可以说)降低了测试门槛,但它也存在一些缺点。尤其是你必须自己创建每个测试用例——而且你只能测试你愿意编写的用例数量。你编写的用例越少,你的测试就越有可能错过代码中的错误。

sort_this_list为了说明为什么这可能是一个问题,让我们看一下上一节中对该函数的测试:

# test_sorted_list.py
def test_sort_this_list():
    assert sort_this_list([5, 3, 1, 4, 2]) == [1, 2, 3, 4, 5] # True
    assert sort_this_list(['a', 'd', 'c', 'e', 'b']) == ['a', 'b', 'c', 'd', 'e'] # True

这两个断言都返回True。因此,如果您只测试了这两个值,您可能会认为该sort_this_list函数始终返回所需的结果。

但如果添加第三个潜在返回值:

# test_sorted_list.py
def test_sort_this_list():
    assert sort_this_list([5, 3, 1, 4, 2]) == [1, 2, 3, 4, 5] 
    assert sort_this_list(['a', 'd', 'c', 'e', 'b']) == ['a', 'b', 'c', 'd', 'e'] 
    # Add a new test case:
    assert sort_this_list(['a', 2, 'c', 3, 'b', 1]) == ['a', 'b', 'c', 1, 2, 3]

然后运行测试...你会遇到一个错误:

TypeError: '<' not supported between instances of 'int' and 'str'

事实证明,sort_this_list当列表同时包含整数和字符串时,该函数无法按预期工作。也许你已经知道这一点,但如果没有特定的测试用例,你也许永远不会知道这一点。

即使存在这些限制,基于示例的测试仍将是软件测试的常态。不过,在本指南的其余部分,我们将探讨另一种技术。该技术旨在补充您现有的(可能基于示例的)测试,并提高代码的测试覆盖率。

基于属性的测试简介

当思考基于示例的测试的局限性时,许多问题浮现在脑海中。如果你想测试数百个案例,或者一些你自己做梦也想不到的案例,该怎么办?

基于属性的测试是一种不同的方法,可以帮助解决这个问题。使用基于属性的测试,您无需手动生成精确的值。而是由计算机自动完成。

作为开发人员,你要做的是:

  • 指定要生成的值。
  • 无论确切值如何,断言保证(或属性)都是真实的。

使用假设的一个例子

为了将基于属性的测试付诸实践,我们来看一个使用Hypothesis的示例。Hypothesis 是一个用于生成测试用例的 Python 库。我们选择 Hypothesis 的主要原因是我们使用 Python,同时也因为它的文档清晰详尽。

让我们使用sort_this_list之前的函数。提醒一下,它看起来是这样的:

# test_sorted_list.py
def sort_this_list(input_list):
    sorted_list = sorted(input_list)
    return sorted_list

现在让我们用 Hypothesis 编写一个基于属性的测试。为了限制范围,我们只测试整数列表:

# test_sorted_list.py
import hypothesis.strategies as some
from hypothesis import given, settings

# Use the @given decorator to guide Hypothesis to the input value needed:
@given(input_list=some.lists(some.integers()))
# Use the @settings object to set the number of cases to run:
@settings(max_examples=10000)
def test_sort_this_list_properties(input_list):
    sorted_list = sort_this_list(input_list)

    # Regardless of input, sorting should never change the size:
    assert len(sorted_list) == len(input_list)

    # Regardless of input, sorting should never change the set of distinct elements:
    assert set(sorted_list) == set(input_list)

    # Regardless of input, each element in the sorted list should be
    # lower or equal to the value that comes after it:
    for i in range(len(sorted_list) - 1):
        assert sorted_list[i] <= sorted_list[i + 1]

注意:如果您在自己的机器上进行操作,请确保安装Hypothesis ,然后可以使用pytest运行测试

就这样,你的第一个基于属性的测试就完成了🎉

这里特别重要的是函数装饰器的使用@given

@given(input_list=some.lists(some.integers()))

这指定您想要一个随机整数列表作为输入值,并断言无论确切输入如何都为真的属性

该测试的另一个重要特点是对象的使用@settings

@settings(max_examples=10000)

这里使用max_examples设置来指示在终止之前运行的最大满足条件的测试用例数。默认值为100,在本例中设置为10000

一开始,运行数万个测试用例可能会让人觉得有点多余——但在基于属性的测试领域,这些数字是合理的。就连 Hypothesis 文档也建议将此值设置为远高于默认值,否则可能会遗漏一些不常见的错误。

回到示例测试,如果添加一条print(input_list)语句,您可以查看生成的 10,000 个不同的输入值

[]
[92]
[66, 24, -25219, 94, -28953, 31131]
[-16316, -367479896]
[-7336253322929551029, -7336253322929551029, 27974, -24308, -64]
...

注意:您的值可能与我们的示例不同 - 这没关系。您可能不想打印 10,000 个示例列表 - 这也没关系。您可以相信我们的说法。

运行次数和生成数据的具体信息均可配置。稍后将详细介绍。

什么可以成为财产?

通过这种测试方式,无论确切的输入是什么,属性都是被测试函数的真实属性。

让我们看看这个定义如何应用于前一个test_sort_this_list_properties函数的断言示例:

  • len(sorted_list) == len(input_list):这里测试的属性是列表的长度。排序后的列表的长度始终与原始列表相同(无论具体列表项是什么)。
  • sorted_list[i] <= sorted_list[i + 1]:此属性是指排序后的列表中的每个元素都按升序排列。无论原始列表的内容是什么,这都应该成立。

基于属性的测试与基于示例的测试有何不同?

虽然基于属性的测试与基于示例的测试源于不同的概念,但它们有许多共同的特征。以下比较了编写给定测试所需的步骤,可以说明这一点:

基于示例 基于财产
1. 设置一些示例数据 1. 定义符合规范的数据类型
2.对数据进行一些操作 2.对数据进行一些操作
3. 对结果进行断言 3. 断言结果的属性

在某些情况下,使用基于属性的测试是值得的。但基于示例的测试也同样如此。它们可以,而且很有可能在同一个代码库中共存。

因此,如果您担心必须重写整个测试套件才能尝试基于属性的测试,请不要担心。我们不建议这样做。

示例属性及其测试方法

到目前为止,您已经编写了第一个基于属性的测试,并对许多列表进行了排序🎉。但是,对列表进行排序并不能代表您在实际应用中如何使用基于属性的测试。因此,我们收集了三个示例属性,并在本节中指导您如何使用它们进行软件测试。

所有示例将继续使用假设检验库及其@given函数装饰器。

永远不应抛出意外异常

上一个test_sorted_list_properties函数默认测试的是代码没有抛出任何异常。代码不抛出任何异常(或者更通俗地说,只抛出预期和文档中记录的异常,并且永远不会导致段错误)是一个属性。这个属性很容易测试,尤其是在代码包含大量内部断言的情况下。

作为示例,我们使用json.loadsPython 标准库中的函数。然后,测试该函数是否无论输入如何json.loads都不会抛出任何异常:json.JSONDecodeError

# test_json_decode.py
@given(some.text())
def test_json_loads(input_string):
    try:
        json.loads(input_string)
    except json.JSONDecodeError:
        return

当您运行测试文件时,它会通过🎉所以信念在测试下得到了坚持!

编码和解码后的值不应该改变

一个常见的测试属性被称为对称性。对称性在某些运算中证明了解码一个编码值总是会得到原始值。

让我们将它应用到base32-crockford ,一个用于Base32编码格式的 Python 库

# test_base32_crockford.py
@given(some.integers(min_value=0))
def test_base32_crockford(input_int):
      assert base32_crockford.decode(base32_crockford.encode(input_int)) == input_int

由于此解码方案仅适用于非负整数,因此您需要指定输入数据的生成策略some.integers(min_value=0)。这就是为什么在本例中,将 添加到@given装饰器中。它限制 Hypothesis 仅生成最小值为零的整数。

测试再次通过🎉

一个简单的方法仍然应该给出相同的结果

有时,你可能会通过一种幼稚、不切实际的方式获得所需的解决方案,而这种方式在生产代码中是不可接受的。这可能是因为执行时间太慢、内存消耗太高,或者需要一些在生产环境中无法安装的特定依赖项。

例如,考虑计算(任意大小)整数中设置位的数量,其中您可以从pygmp2库中获得优化解决方案。

让我们将其与一个较慢的解决方案进行比较,该解决方案将整数转换为二进制字符串(使用Python 标准库中的bin函数),然后计算"1"其中字符串的出现次数:

# test_gmpy_popcount.py
def count_bits_slow(input_int):
    return bin(input_int).count("1")

@given(some.integers(min_value=0))
@settings(max_examples=500)
def test_gmpy2_popcount(input_int):
    assert count_bits_slow(input_int) == gmpy2.popcount(input_int)

为了说明的目的,此示例指定了一个@settings(max_examples=500)装饰器来调整要生成的输入值的默认数量。

测试通过🎉——表明优化的、难以理解的代码gmpy2.popcount与速度较慢但不太复杂的函数给出相同的结果count_bits_slow

注意:如果这是引入 gmpy2 作为依赖项的唯一原因,那么最好对其进行基准测试,看看它的性能改进是否真的会超过依赖项的成本和重量。

使用基于属性的测试查找错误

我们已经了解了基于属性的测试的概念,并了解了各种属性的实际作用——这很棒。但基于属性的测试的卖点之一是,它们应该能帮助我们发现更多错误。而我们目前还没有发现任何错误。

那么我们去打猎吧。

在这个例子中,我们使用json5库进行JSON5序列化。当然,它有点小众,但它也是一个较新的项目。这意味着与更成熟的库相比,你更有可能发现 bug。

json5 库包含:

  • JSON5 的一个特性是它是 JSON 的超集。
  • 另一个属性证明反序列化序列化的字符串应该返回原始对象。

让我们在测试中使用这些属性:

# test_json5_decode.py
import json
from string import printable

import hypothesis.strategies as some
import json5
from hypothesis import example, given, settings

# Construct a generator of arbitrary objects to test serialization on:
some_object = some.recursive(
    some.none() | some.booleans() | some.floats(allow_nan=False) | some.text(printable),
    lambda children: some.lists(children, min_size=1)
    | some.dictionaries(some.text(printable), children, min_size=1),
)

@given(some_object)
def test_json5_loads(input_object):
    dumped_json_string = json.dumps(input_object)
    dumped_json5_string = json5.dumps(input_object)

    parsed_object_from_json = json5.loads(dumped_json_string)
    parsed_object_from_json5 = json5.loads(dumped_json5_string)

    assert parsed_object_from_json == input_object
    assert parsed_object_from_json5 == input_object

创建任意对象some_object的生成器后,您可以验证前面提到的属性的各个方面:使用序列化输入。然后,使用库反序列化这两个对象,并断言已获取原始对象。jsonjson5json5

但是这样做会遇到问题。在以下json5.dumps(input_object)语句中,您会在库内部引发异常json5

    def _is_ident(k):
        k = str(k)
>       if not _is_id_start(k[0]) and k[0] not in (u'$', u'_'):
E       IndexError: string index out of range

注意:如果您正在关注并且想要自己发现错误,请取消注释json5==0.9.3从文件中删除json5requirements.txt

除了像往常一样显示堆栈跟踪之外,您还会收到一条显示失败假设的信息消息- 也称为导致测试失败的生成数据:

# ------------- Hypothesis ------------- #
Falsifying example: test_json5_loads(
    input_object={'': None},
)

使用{'': None}输入数据导致了这个问题,并立即报告了这个问题。找到问题后,我们修复了该错误。此修复程序已在 json5 库 0.9.4 版本中发布。

我们想补充一下,修复已在报告后 20 分钟内合并并发布。json5 维护者@dpranke的工作非常出色👏

但未来又如何呢?你怎么能确定这个问题不会再次出现呢?

由于当前生成的数据包含一个麻烦的输入({'': None}),因此您需要确保始终使用此输入。即使有人调整了some_object生成器或更新了所使用的 Hypothesis 版本,也应该如此。

以下修复使用@example装饰器向生成的输入添加硬编码示例:

--- test_json5_decode_orig.py   2020-03-27 09:48:24.000000000 +0100
+++ test_json5_decode.py    2020-03-27 09:48:32.000000000 +0100
@@ -14,6 +14,7 @@

 @given(some_object)
 @settings(max_examples=500)
+@example({"": None})
 def test_json5_loads(input_object):
     dumped_json_string = json.dumps(input_object)
     dumped_json5_string = json5.dumps(input_object)

发现错误✅ 错误已修复✅ 一切顺利🎉

可用库

本指南使用了Python 的Hypothesis库。但还有很多功能没有涵盖,而且如前所述,它的文档非常详尽。如果您是 Python 用户,我们建议您查看一下。

如果你不使用 Python,也没问题。还有其他几个用于基于属性的测试的库,支持多种语言:

结论

基于示例的测试短期内不会消失。但我们希望,在阅读本指南后,您能够有动力将一些基于属性的测试纳入您的代码库。

我们还有一些疑问......

  • 为什么您之前不使用基于属性的测试?
  • 看完本指南后,您愿意尝试吗?
  • 您是否有兴趣阅读另一篇扩展该主题的文章?

Meeshkan,我们致力于改进人们测试产品的方式。所以,无论您喜欢还是讨厌这份指南,我们都希望听到您的反馈。请在下方留言、在 Twitter 上联系我们,或在 Gitter 上联系我们,分享您的想法。

继续阅读 https://dev.to/meeshkan/from-1-to-10-000-test-cases-in-under-an-hour-a-beginner-s-guide-to-property-based-testing-1jf8
PREV
在 Windows 中的另一个驱动器上安装 WSL
NEXT
2023 年 7 大 Next.js 动画库