「代码测试」使用假设和 Pytest 在 Python 中开始基于属性的测试插图

本教程将是您进行基于属性的测试的温和指南。基于属性的测试是一种测试哲学;一种接近测试的方法,就像单元测试一样,是一种测试哲学,在这种哲学中,我们编写测试来验证代码的各个组件。

通过学习本教程,您将:

  • 了解什么是基于属性的测试;
  • 了解使用基于属性的测试的主要好处;
  • 查看如何使用假设创建基于属性的测试;
  • 尝试一个小挑战来理解如何编写好的基于属性的测试;和
  • 探索可以零开销使用基于属性的测试的几种情况。

什么是基于属性的测试?

在最常见的测试类型中,您通过运行代码编写测试,然后检查您获得的结果是否与您预期的参考结果相匹配。这与基于属性的测试形成对比,在基于属性的测试中,您编写测试来检查结果是否满足某些属性。这种视角的转变使基于属性的测试(使用假设)成为适用于各种场景的绝佳工具,例如模糊测试或测试往返。

在本教程中,我们将学习基于属性的测试背后的概念,然后将这些概念付诸实践。为此,我们将使用三种工具:Python、pytest 和 Hypothesis。

  • Python将成为我们编写需要测试的函数和测试的编程语言。
  • pytest将是测试框架。
  • 假设将成为支持基于属性的测试的框架。

Python 和 pytest 都非常简单,即使您不是 Python 程序员或 pytest 用户,您也应该能够跟进并从学习基于属性的测试中受益。

设置您的环境以进行后续操作

如果你想跟随本教程并自己运行代码片段和测试——这是强烈推荐的——这里是你如何设置你的环境。

安装 Python 和 pip

首先确保安装了最新版本的 Python。前往Python 下载页面并为自己获取最新版本。然后,确保您的 Python 安装也安装了 pip。[ pip ] 是 Python 的包安装程序,您可以通过运行以下命令检查您的计算机上是否安装了它:

python -m pip --version

(这假定 python是在您的计算机上运行 Python 的命令。)如果未安装 pip,请按照其安装说明进行操作。

安装 pytest 和 Hypothesis

有了pip之后,Python测试框架pytest和基于属性的测试框架Hypothesis很容易安装。您所要做的就是运行此命令:

python -m pip install pytest hypothesis --upgrade

这会告诉 pip 安装 pytest 和 Hypothesis,此外,如果已经安装了任何软件包,它还会告诉 pip 更新到更新的版本。

为确保 pytest 已正确安装,您可以运行以下命令:

> python -m pytest --version
pytest 7.2.0

您机器上的输出可能会显示不同的版本,具体取决于您安装的 pytest 的确切版本。

为确保 Hypothesis 已正确安装,您必须通过运行以下命令打开 Python REPL:

python

然后,在 REPL 中,键入import hypothesis. 如果 Hypothesis 被正确安装,它应该看起来什么都没发生。紧接着,您可以检查您安装的版本hypothesis.__version__。因此,您的 REPL 会话看起来像这样:

>>> import hypothesis
>>> hypothesis.__version__
'6.60.0'

您的第一个基于属性的测试

在本节中,我们将为一个小函数编写第一个基于属性的测试。这将展示如何使用假设编写基本测试。

要测试的函数

假设我们实现了一个gcd(n, m)计算两个整数的最大公约数的函数。( and的最大公约数是能整除nandm的最大整数。)更重要的是,假设我们的实现处理正整数和负整数。这是这个实现的样子:dn m

def gcd(n, m):
    """Compute the GCD of two integers by Euclid's algorithm."""

    n, m = abs(n), abs(m)
    n, m = min(n, m), max(n, m)  # Sort their absolute values.
    while m % n:         # While `n` doesn't divide into `m`:
        n, m = m % n, n  # update the values of `n` amd `m`.
    return n

如果将其保存到一个文件中,比如gcd.py,然后运行它:

> python -i gcd.py

您将输入一个已定义函数的交互式 REPL。这让你可以玩一下:

λ python -i main.py
>>> gcd(15, 6)
3
>>> gcd(15, 5)
5
>>> gcd(-9, 15)
3

现在该函数正在运行并且看起来是正确的,我们将使用 Hypothesis 对其进行测试。

属性测试

基于属性的测试与标准 (pytest) 测试没有太大区别,但存在一些关键差异。例如,我们不将输入写入函数gcd,而是让 Hypothesis 生成任意输入。然后,我们编写断言来确保解决方案满足它应该满足的属性,而不是对预期输出进行硬编码。

因此,要编写基于属性的测试,您需要确定您的答案应满足的属性。

值得庆幸的是,我们已经知道结果gcd必须满足的属性:

“[…] 两个或更多整数的最大公约数 (GCD) […] 是除以每个整数的最大正整数。”

因此,从维基百科的引用中,我们知道 ifd是 的结果gcd(n, m),那么:

  1. d是积极的;
  2. dn;
  3. dm; 和
  4. 没有其他数字大于nm

为了将这些属性转化为测试,我们首先编写test_接受与函数相同输入的函数的签名gcd

def test_gcd(n, m):
    ...

(前缀test_对于 Hypothesis 并不重要。我们将 Hypothesis 与 pytest 一起使用,pytest 查找以 开头的函数test_,因此这就是我们的函数被调用的原因test_gcd。)

参数nm也是 的参数gcd,将由 Hypothesis 填充。现在,我们只假设它们可用。

如果nm是可用的参数并且我们要为其测试函数gcd,我们必须首先调用gcdwithn然后m保存结果。在使用提供的参数调用 gcd 并获得答案后,​​我们根据上面列出的四个属性测试答案。

考虑到这四个属性,我们的测试函数可能如下所示:

def test_gcd(n, m):
    d = gcd(n, m)

    assert d > 0  # 1) `d` is positive
    assert n % d == 0  # 2) `d` divides `n`
    assert m % d == 0  # 3) `d` divides `m`

    # 4) no other number larger than `d` divides both `n` and `m`
    for i in range(d + 1, min(n, m)):
        assert (n % i) or (m % i)

继续将此测试函数gcd放在文件中的函数旁边gcd.py。通常,测试与被测试的代码位于不同的文件中,但这是一个很小的示例,我们可以将所有内容都放在同一个文件中。

插入假设

我们已经编写了测试函数,但我们仍然没有使用假设来支持测试。n让我们继续使用 Hypothesis的魔力m为我们的函数 gcd生成一堆参数。为了做到这一点,我们需要弄清楚我们的函数 gcd 应该处理的所有合法输入是什么。

对于我们的函数 gcd,有效输入都是整数,所以我们需要告诉 Hypothesis 生成整数并将它们输入test_gcd。为此,我们需要导入一些东西:

from hypothesis import given, strategies as st

given是我们将用来告诉 Hypothesis 一个测试函数需要给数据的东西。子strategies模块是包含许多知道如何生成数据的工具的模块。

通过这两个导入,我们可以注释我们的测试:

from hypothesis import given, strategies as st

def gcd(n, m):
    ...

@given(st.integers(), st.integers())
def test_gcd(n, m):
    d = gcd(n, m)
    # ...

您可以将装饰器@given(st.integers(), st.integers())理解为“测试函数需要被赋予一个整数,然后是另一个整数”。要运行测试,您可以使用pytest

λ pytest gcd.py

(注意:根据您的操作系统和配置方式,pytest 可能不会出现在您的路径中,并且该命令pytest gcd.py可能不起作用。如果是这种情况,您可以改用该命令python -m pytest gcd.py。)

一旦你这样做,Hypothesis 就会向你尖叫一条错误消息,说你得到了一个ZeroDivisionError. 让我们通过查看运行测试的输出的底部来尝试理解 Hypothesis 告诉我们的内容:

...
gcd.py:8: ZeroDivisionError
--------------------------------- Hypothesis ----------------------------------
Falsifying example: test_gcd(
    m=0, n=0,
)
=========================== short test summary info ===========================
FAILED gcd.py::test_gcd - ZeroDivisionError: integer division or modulo by zero
============================== 1 failed in 0.67s ==============================

这表明测试失败并带有ZeroDivisionError,并且读取“Falsifying example: …”的行包含有关使我们的测试失败的测试用例的信息。在我们的例子中,这是n = 0and m = 0。因此,假设告诉我们,当参数均为零时,我们的函数失败,因为它引发了一个ZeroDivisionError.

问题在于模运算符的使用%,它不接受零的正确参数。%如果为零,则的右参数n为零,在这种情况下,结果应为m。添加一个 if 语句是一个可能的解决方法:

def gcd(n, m):
    """Compute the GCD of two integers by Euclid's algorithm."""

    n, m = abs(n), abs(m)
    n, m = min(n, m), max(n, m)  # Sort their absolute values.

    if not n:
        return m

    while m % n:         # While `n` doesn't divide into `m`:
        n, m = m % n, n  # update the values of `n` and `m`.
    return n

然而,假设仍然不会快乐。如果您使用 再次运行测试pytest gcd.py,您会得到以下输出:

> pytest gcd.py
...

FAILED gcd.py::test_gcd - assert 0 > 0

这一次,问题是第一个应该满足的属性。我们可以知道这一点,因为假设告诉我们哪个断言失败了,同时也告诉我们哪个论点导致了那个失败。事实上,如果我们进一步查看输出,这就是我们所看到的:

n = 0, m = 0   <-- Hypothesis tells you what arguments broke the test

    @example(0, 0)
    @given(st.integers().filter(lambda n: n != 0), st.integers())
    def test_gcd(n, m):
        d = gcd(n, m)

>       assert d > 0  # 1) `d` is positive
E       assert 0 > 0
E       Falsifying explicit example: test_gcd(
E           n=0, m=0,
E       )

gcd.py:23: AssertionError
===================== short test summary info =====================
FAILED gcd.py::test_gcd - assert 0 > 0

这一次,问题真的不是我们的错。当两个参数都为零时,最大公约数未定义,因此我们的函数不知道如何处理这种情况是可以的。值得庆幸的是,Hypothesis 让我们可以自定义用于生成论点的策略。特别是,我们可以说我们只想生成介于最小值和最大值之间的整数。

下面的代码更改了测试,以便它只运行 1 到 100 之间的整数作为第一个参数 ( n) 和 -500 到 500 之间的第二个参数 ( m):

@given(
    st.integers(min_value=1, max_value=100),
    st.integers(min_value=-500, max_value=500),
)
def test_gcd(n, m):
    d = gcd(n, m)
    # ...

这就对了!这是您的第一个基于属性的测试。

为什么要费心进行基于属性的测试?

要编写好的基于属性的测试,您需要仔细分析您的问题,以便能够写下所有相关的属性。这可能看起来很麻烦。然而,使用像 Hypothesis 这样的工具有非常实际的好处:

  • 假设可以为您生成数十个或数百个测试,而您通常只会编写其中的几个;
  • 您手写的测试通常只会涵盖您已经想到的边缘情况,而假设不会有这种偏见;和
  • 考虑您的解决方案以找出其属性可以让您更深入地了解问题,从而获得更好的解决方案。

这些只是使用基于属性的测试的一些优势。

免费使用假设

在某些场景中,您可以基本上免费地使用基于属性的测试(即,无需花费您宝贵的脑力),因为您甚至不需要考虑属性。让我们来看两个这样的场景。

测试往返

假设是测试往返的好工具。例如,Python 中的内置函数intstr应该往返。也就是说,如果x是一个整数,那么int(str(x))应该仍然是x。换句话说,转换x为字符串然后再次转换为整数不应更改其值。

我们可以为此编写一个简单的基于属性的测试,利用 Hypothesis 为我们生成数十个测试这一事实。将其保存在 Python 文件中:

from hypothesis import given, strategies as st

@given(st.integers())
def test_int_str_roundtripping(x):
    assert x == int(str(x))

现在,用 pytest 运行这个文件。你的测试应该通过了!

模糊测试

您是否注意到,在我们上面的 gcd 示例中,我们第一次运行 Hypothesis 时得到了一个ZeroDivisionError? 测试失败,不是因为断言,而是因为我们的函数崩溃了。

假设可以用于这样的测试。您不需要编写单个属性,因为您只是使用假设来查看您的函数是否可以处理不同的输入。当然,即使是有问题的函数也可以通过这样的模糊测试,但这有助于捕获代码中的某些类型的错误。

与黄金标准比较

有时,您想测试一个函数,该函数f计算的东西可以由其他函数计算f_alternative。你知道这个其他功能是正确的(这就是为什么你称它为“黄金标准”),但你不能在生产中使用它,因为它非常慢,或者它消耗大量资源,或者出于某些其他原因的组合。

如果可以f_alternative在测试环境中使用该功能,则合适的测试如下所示:

@given(...)
def test_f(...):
    assert f(...) == f_alternative(...)

如果可能,这种类型的测试非常强大,因为它直接测试您的解决方案是否适用于一系列不同的参数。

例如,如果您重构了一段旧代码,可能是为了简化其逻辑或提高其性能,Hypothesis 会让您相信您的新功能将按预期工作。

财产完整性的重要性

在本节中,您将了解在列出相关属性时进行全面检查的重要性。为了说明这一点,我们将对名为 的函数进行基于属性的测试,my_sort该函数是接受整数列表的排序函数的实现。

结果排序

在考虑 的结果my_sort满足的属性时,您会想到一个显而易见的事情:my_sort必须对 的结果进行排序。

因此,您开始断言此属性已得到满足:

@given(...)
def test_my_sort(int_list):
    result = my_sort(int_list)
    for a, b in zip(result, result[1:]):
        assert a <= b

现在,唯一缺少的是生成整数列表的适当策略。值得庆幸的是,Hypothesis 知道一种生成列表的策略,称为lists. 您需要做的就是给它一个生成列表元素的策略。

from hypothesis import given, strategies as st


@given(st.lists(st.integers()))
def test_my_sort(int_list):
    result = my_sort(int_list)
    for a, b in zip(result, result[1:]):
        assert a <= b

现在已经编写了测试,这是一个挑战。将此代码复制到名为my_sort.py. 在导入和测试之间,定义一个my_sort错误的函数(即,编写一个不对整数列表进行排序的函数),但如果使用pytest my_sort.py. (当你准备好剧透时,请继续阅读。)

请注意,我们正在测试的唯一属性是“结果的所有元素都已排序”,因此我们可以返回任何我们想要的结果,只要它已排序。这是我的假实现my_sort

def my_sort(int_list):
    return []

这通过了我们的属性测试,但显然是错误的,因为我们总是返回一个空列表。那么,我们是否缺少一个属性?也许。

长度是一样的

我们可以尝试添加另一个明显的属性,即输入和输出显然应该具有相同的长度。这意味着我们的测试变成:

@given(st.lists(st.integers()))
def test_my_sort(int_list):
    result = my_sort(int_list)

    assert len(result) == len(int_list)

    for a, b in zip(result, result[1:]):
        assert a <= b

现在测试已经改进,这里是一个挑战。写一个通过这个测试的新版本,my_sort但仍然是错误的。(当你准备好剧透时,请继续阅读。)

请注意,我们只测试结果的长度以及它的元素是否已排序,但我们不测试结果中包含哪些元素。因此,这个伪造的实现my_sort会起作用:

def my_sort(int_list):
    return list(range(len(int_list)))

使用正确的数字

为了解决这个问题,我们可以添加一个明显的属性,即结果应该只包含原始列表中的数字。使用集合,这很容易测试:

@given(st.lists(st.integers()))
def test_my_sort(int_list):
    result = my_sort(int_list)

    assert len(result) == len(int_list)  # Should have same length.

    assert set(result) <= set(int_list)  # Should use numbers from input.

    for a, b in zip(result, result[1:]):  # Result is actually sorted.
        assert a <= b

现在我们的测试已经改进,我又面临另一个挑战。你能写一个my_sort通过这个测试的假版本吗?(当你准备好剧透时,请继续阅读)。

my_sort这是通过上述测试的假版本:

def my_sort(int_list):
    if not int_list:
        return []
    return len(int_list) * [int_list[0]]

这里的问题是我们对新属性不够精确。事实上,set(result) <= set(int_list)确保我们只使用原始列表中可用的数字,但不能确保我们使用所有这些数字。更重要的是,我们无法通过简单地替换<=with来修复它==。你能看出为什么吗?我会给你一个提示。如果您只是将 the 替换为<===,那么测试将变为:

@given(st.lists(st.integers()))
def test_my_sort(int_list):
    result = my_sort(int_list)

    assert len(result) == len(int_list)  # Should have same length.

    assert set(result) == set(int_list)  # Same numbers as input.

    for a, b in zip(result, result[1:]):  # Result is actually sorted.
        assert a <= b

然后你可以写这个传递版本my_sort仍然是错误的:

def my_sort(int_list):
    if not int_list:
        return []

    s = sorted(set(int_list))
    return s + [s[-1]] * (len(int_list) - len(s))

这个版本是错误的,因为它重用了原始列表的最大元素,而没有考虑每个整数应该使用的次数。例如,对于输入列表 [1, 1, 2, 2, 3, 3],结果应保持不变,而此版本my_sort返回[1, 2, 3, 3, 3, 3].

最后的考验

正确和完整的测试必须考虑每个数字在原始列表中出现的次数,这是内置set不准备做的事情。相反,可以使用collections.Counter标准库中的:

@given(st.lists(st.integers()))
def test_my_sort(int_list):
    result = my_sort(int_list)

    assert len(result) == len(int_list)  # Should have same length.

    assert Counter(result) == Counter(int_list)  # Should use numbers from input.

    for a, b in zip(result, result[1:]):  # Result is actually sorted.
        assert a <= b

所以,至此,您的测试功能test_my_sort就完成了。至此,再也不可能糊弄测试了!也就是说,测试通过的唯一方法是 ifmy_sort是一个真正的排序函数。

使用属性和具体示例

本节表明,您测试的属性应该经过深思熟虑,您应该努力提出一组尽可能具体的属性。如有疑问,最好拥有看起来多余的属性,而不是拥有太少的属性。

您可以遵循的另一种策略可以帮助减轻因属性集不足而带来的危险,那就是将基于属性的测试与其他形式的测试混合使用,这是完全合理的。

例如,在基于属性的测试之上test_my_sort,您可以添加以下测试:

def test_my_sort_specific_examples():
    assert my_sort([]) == []
    assert my_sort(list(range(10)[::-1])) == list(range(10))
    assert my_sort([42, 73, 0, 16, 10]) == [0, 10, 16, 42, 73]

结论

本文介绍了我们添加了基于属性的测试的两个函数示例。我们只介绍了使用假设来运行基于属性的测试的基础知识,但更重要的是,我们介绍了使开发人员能够推理和编写完整的基于属性的测试的基本概念。

基于属性的测试不是一种万能的解决方案,这意味着您永远不必编写任何其他类型的测试,但它确实具有您应该尽可能利用的特性。特别是,我们看到使用 Hypothesis 进行基于属性的测试的好处在于:

  • 假设可以为您生成数十个或数百个测试,而您通常只会编写其中的几个;
  • 您手写的测试通常只会涵盖您已经想到的边缘情况,而假设不会有这种偏见;和
  • 考虑您的解决方案以找出其属性可以让您更深入地了解问题,从而获得更好的解决方案。

本文还讨论了编写基于属性的测试时的几个常见问题,并列出了可以在没有开销的情况下使用基于属性的测试的场景。