Python高级技术之:`Python`的`property-based testing`:`Hypothesis`库的实践。

各位观众老爷,晚上好!我是你们的老朋友,今天要跟大家聊聊一个听起来高大上,用起来贼爽的Python高级技术:Property-based testing,以及它的明星实现——Hypothesis库。

一、 啥是Property-based testing?为啥要用它?

传统的单元测试,我们都是手搓一些特定的输入,然后断言输出是否符合预期。这种方式对于简单逻辑还行,但面对复杂场景,很容易挂一漏万。想象一下,你要测试一个函数,它接收一个整数列表作为输入,然后返回一个排序后的列表。你要测试多少种情况?空列表、只有一个元素的列表、已经排序好的列表、倒序的列表、包含重复元素的列表… 简直没完没了!

Property-based testing (PBT) 就牛逼了。它不是让你写具体的测试用例,而是让你描述输入数据的性质(property),以及输出结果应该满足的性质(property)。然后,PBT框架(比如Hypothesis)会自动生成大量的、满足你定义的性质的随机输入,用这些输入去测试你的代码,并检查输出是否满足你定义的性质。如果发现问题,它还会自动缩小问题范围,找到导致bug的最小测试用例。

用人话说,就好比你不是自己去给算法出题,而是告诉算法:“嘿,我出的题必须是数字,而且最后答案要比输入的数字大!”然后算法自己给自己出海量符合规则的题,并验证答案是否正确。

为啥要用PBT?

  1. 覆盖面更广: 自动生成测试用例,覆盖更多的边界情况和异常情况,比手写测试用例更全面。
  2. 发现隐藏Bug: 能发现一些你意想不到的Bug,这些Bug可能在手写测试用例中很难发现。
  3. 减少测试代码量: 你只需要描述输入和输出的性质,而不需要编写大量的具体测试用例。
  4. 更容易维护: 当代码逻辑发生变化时,你只需要修改性质的描述,而不需要修改大量的具体测试用例。
  5. 自动最小化测试用例: 一旦发现Bug,PBT会自动缩小导致Bug的测试用例,方便你快速定位问题。

二、 Hypothesis库:你的PBT好帮手

Hypothesis是一个强大的Python PBT库,它提供了丰富的策略(strategies)来生成各种类型的测试数据,以及强大的测试用例缩减功能。

2.1 安装Hypothesis

pip install hypothesis

2.2 基本使用

from hypothesis import given
import hypothesis.strategies as st

@given(st.integers())
def test_int_squared_positive(x):
  """
  测试整数的平方是否大于等于0
  """
  assert x * x >= 0

@given(st.lists(st.integers()))
def test_list_sum_length(numbers):
  """
  测试列表的和是否小于等于列表长度的平方
  """
  assert sum(numbers) <= len(numbers) ** 2

上面的代码中:

  • @given装饰器:告诉Hypothesis,这是一个需要进行PBT测试的函数。
  • st.integers():这是一个策略(strategy),用于生成整数。
  • st.lists(st.integers()):这是一个策略,用于生成整数列表。
  • assert语句:用于断言输出是否满足我们定义的性质。

运行这段代码,Hypothesis会自动生成大量的整数和整数列表,并用这些数据来测试test_int_squared_positivetest_list_sum_length函数。如果发现任何Bug,Hypothesis会报错,并告诉你导致Bug的最小测试用例。

2.3 策略(Strategies):生成测试数据的利器

Hypothesis提供了大量的内置策略,可以生成各种类型的测试数据。

策略类型 描述 示例
integers() 生成整数 st.integers(min_value=0, max_value=100)
floats() 生成浮点数 st.floats(min_value=0.0, max_value=1.0)
characters() 生成字符 st.characters(min_codepoint=65, max_codepoint=90) (A-Z)
text() 生成字符串 st.text(alphabet="abc")
booleans() 生成布尔值 st.booleans()
lists() 生成列表 st.lists(st.integers(), min_size=1, max_size=10)
sets() 生成集合 st.sets(st.integers(), min_size=1, max_size=10)
dictionaries() 生成字典 st.dictionaries(st.text(), st.integers())
tuples() 生成元组 st.tuples(st.integers(), st.text())
one_of() 从多个策略中随机选择一个生成数据 st.one_of(st.integers(), st.text())
sampled_from() 从给定的序列中随机选择一个元素生成数据 st.sampled_from(["a", "b", "c"])
dates() 生成日期 st.dates(min_value=date(2023, 1, 1), max_value=date(2023, 12, 31))
datetimes() 生成日期时间 st.datetimes(min_value=datetime(2023, 1, 1), max_value=datetime(2023, 12, 31))
emails() 生成电子邮件地址 st.emails()
uuids() 生成UUID st.uuids()

2.4 自定义策略(Custom Strategies)

除了内置策略,你还可以自定义策略来生成更符合你需求的测试数据。

from hypothesis import strategies as st

@st.composite
def my_custom_strategy(draw):
  """
  自定义策略,生成一个包含两个元素的元组,第一个元素是0到10之间的整数,第二个元素是该整数的平方。
  """
  x = draw(st.integers(0, 10))
  return (x, x * x)

@given(my_custom_strategy())
def test_custom_strategy(data):
  """
  测试自定义策略生成的数据是否满足预期
  """
  x, y = data
  assert y == x * x

上面的代码中:

  • @st.composite装饰器:告诉Hypothesis,这是一个自定义策略。
  • draw函数:用于从其他策略中抽取数据。
  • my_custom_strategy函数:定义了自定义策略的逻辑。

2.5 假设(Assumptions)

有时候,你可能需要对输入数据进行一些限制,比如要求输入数据必须满足某些条件。可以使用assume函数来排除不符合条件的输入数据。

from hypothesis import given, assume
import hypothesis.strategies as st

@given(st.integers())
def test_divide_by_positive(x):
  """
  测试除以正数
  """
  assume(x > 0)  # 假设x大于0
  result = 10 / x
  assert result > 0

上面的代码中,assume(x > 0)表示只有当x大于0时,才会执行后面的代码。如果x小于等于0,Hypothesis会跳过这个测试用例,并生成一个新的x

2.6 例子:测试一个简单的排序函数

假设我们有一个简单的排序函数:

def sort_list(numbers):
  """
  排序一个列表
  """
  return sorted(numbers)

我们可以使用Hypothesis来测试这个函数:

from hypothesis import given
import hypothesis.strategies as st

@given(st.lists(st.integers()))
def test_sort_list_is_sorted(numbers):
  """
  测试排序后的列表是否已经排序
  """
  sorted_numbers = sort_list(numbers)
  for i in range(len(sorted_numbers) - 1):
    assert sorted_numbers[i] <= sorted_numbers[i+1]

@given(st.lists(st.integers()))
def test_sort_list_same_elements(numbers):
  """
  测试排序后的列表是否包含相同的元素
  """
  sorted_numbers = sort_list(numbers)
  assert len(numbers) == len(sorted_numbers)
  for number in numbers:
    assert number in sorted_numbers

上面的代码中:

  • test_sort_list_is_sorted函数:测试排序后的列表是否已经排序。
  • test_sort_list_same_elements函数:测试排序后的列表是否包含相同的元素。

2.7 高级技巧

  • Shrinking (缩小): 当Hypothesis发现Bug时,它会自动缩小导致Bug的测试用例,找到最小的、仍然能触发Bug的输入。这对于定位问题非常有帮助。
  • Verbosity (详细程度): 可以通过@settings装饰器来控制Hypothesis的详细程度,比如显示生成的测试用例、显示缩小的过程等。
  • Stateful Testing (状态测试): Hypothesis支持状态测试,可以测试有状态的系统,比如测试一个简单的银行账户,可以定义存款、取款等操作,然后使用Hypothesis来生成一系列的操作,并验证账户的状态是否正确。

三、 结合pytest使用 Hypothesis

Hypothesis可以无缝集成到pytest中。

  1. 确保你已经安装了pytest
pip install pytest
  1. 编写测试用例:
import pytest
from hypothesis import given
import hypothesis.strategies as st

def add(x, y):
  return x + y

@given(st.integers(), st.integers())
def test_add_commutative(x, y):
  """
  测试加法是否满足交换律
  """
  assert add(x, y) == add(y, x)

@given(st.integers(), st.integers())
def test_add_associative(x, y):
  """
  测试加法是否满足结合律
  """
  z = 1  # 添加第三个数
  assert add(add(x, y), z) == add(x, add(y, z))

# 故意写一个失败的测试用例
@given(st.integers(), st.integers())
def test_add_always_positive(x, y):
  """
  测试加法结果是否总是正数 (这个测试会失败)
  """
  assert add(x, y) > 0
  1. 运行pytest
pytest your_test_file.py

pytest会自动识别Hypothesis的测试用例,并运行它们。 如果test_add_always_positive测试失败,pytest会显示详细的错误信息,包括导致错误的最小测试用例。

四、 PBT的局限性

PBT虽然强大,但也不是万能的。它也有一些局限性:

  1. 需要一定的学习成本: 需要学习如何定义输入和输出的性质,以及如何使用Hypothesis的策略。
  2. 难以测试复杂的系统: 对于非常复杂的系统,很难定义清晰的性质。
  3. 测试时间可能较长: 由于需要生成大量的测试用例,测试时间可能会比较长。
  4. 不能完全替代传统单元测试: PBT更多的是作为一种补充手段,不能完全替代传统的单元测试。 一些需要特定场景或者特定边界条件的测试,仍然需要手动编写单元测试。

五、 总结

Property-based testing 是一种强大的测试技术,可以帮助你发现隐藏的Bug,提高代码的质量。Hypothesis 是一个优秀的Python PBT库,提供了丰富的策略和强大的功能。希望今天的讲解能够帮助你了解和使用 PBT,让你的代码更加健壮可靠!

总而言之,PBT就像是给你的代码雇了一个全职的“捣蛋鬼”,它会想尽办法去破坏你的代码,让你提前发现潜在的问题,防患于未然。 但你也要记住,这个“捣蛋鬼”需要你的指导,你需要告诉它应该怎么“捣蛋”,才能真正发挥它的作用。 祝大家编码愉快,Bug少少!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注