前途科技
  • 科技
  • AI
    • AI 前沿技术
    • Agent生态
    • AI应用场景
    • AI 行业应用
  • 初创
  • 报告
  • 学习中心
    • 编程与工具
    • 数据科学与工程
我的兴趣
前途科技前途科技
Font ResizerAa
站内搜索
Have an existing account? Sign In
Follow US
Copyright © 2024 AccessPath.com, 前途国际科技咨询(北京)有限公司,版权所有。 | 京ICP备17045010号-1 | 京公网安备 11010502033860号
编程与工具

Python 代码测试利器:Hypothesis 基于属性测试,先于用户发现潜在缺陷

NEXTECH
Last updated: 2025年11月1日 上午5:52
By NEXTECH
Share
182 Min Read
SHARE

开发者应认真对待代码测试。您可能会使用 pytest 编写单元测试,模拟依赖项,并努力提高代码覆盖率。然而,许多开发者在完成测试套件后,心中可能仍萦绕着一个挥之不去的问题。

Contents
那么,究竟什么是基于属性的测试?为何 Hypothesis 举足轻重 / 常见用例搭建开发环境test_encoders.py逐步追踪 Bug总结

“我是否考虑到了所有的边界情况?”

开发者可能会用正数、负数、零和空字符串来测试输入。但那些奇特的 Unicode 字符呢?或者那些非数字(NaN)或无穷大的浮点数?又或者,由空字符串列表组成的列表,甚至是复杂的嵌套 JSON 结构呢?可能的输入空间是巨大的,要想穷尽代码可能出错的无数种方式,尤其是在时间压力下,是极其困难的。

基于属性的测试将这种负担从开发者转移到了工具本身。开发者无需手动挑选测试用例,而是声明一个属性——一个对所有输入都必须成立的真理。Hypothesis 库随后会自动**生成**输入数据(如果需要,可生成数百个),搜寻反例,并且——如果找到一个反例——将其**缩小**到最简单的失败案例,从而极大简化调试过程。

本文将深入探讨基于属性的测试这一强大概念及其在 Hypothesis 中的具体实现。文章将超越简单的函数测试,展示如何有效地测试复杂数据结构和有状态类,以及如何微调 Hypothesis 以实现更健壮、更高效的测试。

那么,究竟什么是基于属性的测试?

基于属性的测试是一种测试方法论,开发者不再为特定的、硬编码的示例编写测试用例,而是定义代码的通用“属性”或“不变量”。一个属性是关于代码行为的高层次陈述,它应该对所有有效输入都成立。随后,开发者可以使用像 Hypothesis 这样的测试框架,它将智能地生成各种输入,并尝试找到一个“反例”——即一个使得开发者所声明的属性不成立的特定输入。

You Might Also Like

Polars 数据分析入门指南:用 Python 高效处理咖啡店数据
Python编程实战:从零开始构建你的专属交互式计算器
揭秘DAX:筛选器如何驱动时间智能函数的幕后逻辑
Python机器人入门指南:使用PyBullet轻松构建3D仿真

使用 Hypothesis 进行基于属性的测试,其核心特点包括:

  • 生成式测试。 Hypothesis 会为开发者生成测试用例,从简单的到异常的,探索开发者可能遗漏的边界情况。
  • 属性驱动。 它将开发者的思维模式从“这个特定输入的输出是什么?”转变为“关于我的函数行为,有哪些普适的真理?”
  • 收缩(Shrinking)。 这是 Hypothesis 的杀手级功能。当它发现一个失败的测试用例(可能很大且复杂)时,它不仅仅是报告。它会自动将输入“收缩”到仍然导致失败的最小、最简单的可能示例,这通常会使调试工作变得异常容易。
  • 有状态测试。 Hypothesis 不仅可以测试纯函数,还可以测试复杂对象在一系列方法调用中的交互和状态变化。
  • 可扩展的策略。 Hypothesis 提供了一个强大的“策略”库,用于生成数据,并允许开发者组合它们或构建全新的策略,以匹配应用程序的数据模型。

为何 Hypothesis 举足轻重 / 常见用例

基于属性的测试最主要的好处在于它能够发现细微的错误,并显著提升对代码正确性的信心,其效果远超单独使用基于示例的测试。它促使开发者更深入地思考代码的契约和隐含假设。

Hypothesis 在以下场景中表现尤为出色:

  • 序列化/反序列化。 一个经典的属性是,对于任何对象 x,decode(encode(x)) 应该等于 x。这非常适合测试处理 JSON 或自定义二进制格式的函数。
  • 复杂业务逻辑。 任何包含复杂条件逻辑的函数都是理想的测试对象。Hypothesis 将探索代码中开发者可能未曾考虑过的执行路径。
  • 有状态系统。 测试类和对象,确保任何有效的操作序列都不会使对象进入损坏或无效状态。
  • 对照参考实现进行测试。 开发者可以声明一个属性,即新的、优化后的函数应始终产生与更直接、已知且作为典范的参考实现相同的结果。
  • 接受复杂数据模型的函数。 测试那些以 Pydantic 模型、数据类或其他自定义对象作为输入的函数。

搭建开发环境

开发者所需的只有 Python 和 pip。我们将安装 pytest 作为测试运行器,Hypothesis 库本身,以及 pydantic,用于后续的一个高级示例。

(base) tom@tpr-desktop:~$ python -m venv hyp-env
(base) tom@tpr-desktop:~$ source hyp-env/bin/activate
(hyp-env) (base) tom@tpr-desktop:~$ 

# 安装 pytest, hypothesis, 和 pydantic
(hyp-env) (base) tom@tpr-desktop:~$ pip install pytest hypothesis pydantic 

# 创建一个新文件夹来存放 Python 代码
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ mkdir hyp-project

Hypothesis 最好与 pytest 等成熟的测试运行器工具结合使用,本文也将采用这种方式。

代码示例 1 — 简单测试

在这个最简单的例子中,有一个计算矩形面积的函数。它应该接受两个大于零的整数参数,并返回它们的乘积。

Hypothesis 测试的定义依赖于两部分:@given 装饰器和一个传递给该装饰器的策略(strategy)。可以把策略理解为 Hypothesis 将生成数据类型来测试函数的方式。下面是一个简单的例子。首先,定义要测试的函数。

# my_geometry.py

def calculate_rectangle_area(length: int, width: int) -> int:
  """
  根据长和宽计算矩形的面积。

  如果任一维度不是正整数,此函数将引发 ValueError。
  """
  if not isinstance(length, int) or not isinstance(width, int):
    raise TypeError("长度和宽度必须是整数。")

  if length <= 0 or width <= 0:
    raise ValueError("长度和宽度必须是正数。")

  return length * width

接下来是测试函数。

# test_rectangle.py

from my_geometry import calculate_rectangle_area
from hypothesis import given, strategies as st
import pytest

# 通过为两个参数使用 st.integers(min_value=1),确保
# Hypothesis 只会为函数生成有效输入。
@given(
    length=st.integers(min_value=1), 
    width=st.integers(min_value=1)
)
def test_rectangle_area_with_valid_inputs(length, width):
    """
    属性:对于任何正整数的长度和宽度,面积应等于它们的乘积。

    此测试确保核心乘法逻辑是正确的。
    """
    print(f"使用有效输入进行测试: length={length}, width={width}")

    # 正在检查的属性是面积的数学定义。
    assert calculate_rectangle_area(length, width) == length * width

将 @given 装饰器添加到函数上,即可将其转换为 Hypothesis 测试。将策略(st.integers)传递给装饰器,表示 Hypothesis 在测试时应为参数生成随机整数,但通过确保任何整数都不能小于一,进一步限制了生成范围。

可以通过以下方式运行此测试。

(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_geometry.py

=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item

test_my_geometry.py Testing with valid inputs: length=1, width=1
Testing with valid inputs: length=6541, width=1
Testing with valid inputs: length=6541, width=28545
Testing with valid inputs: length=1295885530, width=1
Testing with valid inputs: length=1295885530, width=25191
Testing with valid inputs: length=14538, width=1
Testing with valid inputs: length=14538, width=15503
Testing with valid inputs: length=7997, width=1
...
...

Testing with valid inputs: length=19378, width=22512
Testing with valid inputs: length=22512, width=22512
Testing with valid inputs: length=3392, width=44
Testing with valid inputs: length=44, width=44
.

============================================ 1 passed in 0.10s =============================================

默认情况下,Hypothesis 会对函数执行 100 次不同输入的测试。开发者可以通过使用 settings 装饰器来增加或减少测试次数。例如:

from hypothesis import given, strategies as st,settings
...
...
@given(
    length=st.integers(min_value=1), 
    width=st.integers(min_value=1)
)
@settings(max_examples=3)
def test_rectangle_area_with_valid_inputs(length, width):
...
...

#
# 输出
#
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_geometry.py
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item

test_my_geometry.py 
Testing with valid inputs: length=1, width=1
Testing with valid inputs: length=1870, width=5773964720159522347
Testing with valid inputs: length=61, width=25429
.

============================================ 1 passed in 0.06s =============================================

代码示例 2 — 测试经典的“往返”属性

接下来,探讨一个经典的属性:序列化和反序列化应该是可逆的。简而言之,decode(encode(X)) 应该返回 X。

我们将编写一个函数,它接受一个字典并将其编码为 URL 查询字符串。

在 hyp-project 文件夹中创建一个名为 my_encoders.py 的文件。

# my_encoders.py
import urllib.parse

def encode_dict_to_querystring(data: dict) -> str:
    # 这里存在一个 bug:它不能很好地处理嵌套结构
    return urllib.parse.urlencode(data)

def decode_querystring_to_dict(qs: str) -> dict:
    return dict(urllib.parse.parse_qsl(qs))

这是两个基本函数。它们会出什么问题呢?现在,在 test_encoders.py 中测试它们:

test_encoders.py

# test_encoders.py

from hypothesis import given, strategies as st

# 生成带有简单文本键和值的字典的策略
simple_dict_strategy = st.dictionaries(keys=st.text(), values=st.text())

@given(data=simple_dict_strategy)
def test_querystring_roundtrip(data):
    """属性:解码一个已编码的字典应该得到原始字典。"""
    encoded = encode_dict_to_querystring(data)
    decoded = decode_querystring_to_dict(encoded)

    # 需要注意类型:parse_qsl 返回的是字符串值
    # 所以将原始值转换为字符串以进行公平比较
    original_as_str = {k: str(v) for k, v in data.items()}

    assert decoded == original_as_str

现在,可以运行测试了。

(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_encoders.py
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item

test_encoders.py F

================================================= FAILURES =================================================
_______________________________________ test_for_nesting_limitation ________________________________________

    @given(data=st.recursive(
>       # Base case: A flat dictionary of text keys and simple values (text or integers).
                   ^^^
        st.dictionaries(st.text(), st.integers() | st.text()),
        # Recursive step: Allow values to be dictionaries themselves.
        lambda children: st.dictionaries(st.text(), children)
    ))

test_encoders.py:7:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

data = {'': {}}

    @given(data=st.recursive(
        # Base case: A flat dictionary of text keys and simple values (text or integers).
        st.dictionaries(st.text(), st.integers() | st.text()),
        # Recursive step: Allow values to be dictionaries themselves.
        lambda children: st.dictionaries(st.text(), children)
    ))
    def test_for_nesting_limitation(data):
        """
        此测试断言解码后的数据结构与原始数据结构匹配。
        它将失败,因为 urlencode 会扁平化嵌套结构。
        """
        encoded = encode_dict_to_querystring(data)
        decoded = decode_querystring_to_dict(encoded)

        # 这是一个刻意简化的断言。对于嵌套字典,它将失败,
        # 因为 `decoded` 版本将包含一个字符串化的内部字典,
        # 而 `data` 版本将包含一个真正的内部字典。
        # 这就是我们揭示 bug 的方式。
>       assert decoded == data
E       AssertionError: assert {'': '{}'} == {'': {}}
E
E         Differing items:
E         {'': '{}'} != {'': {}}
E         Use -v to get more diff
E       Falsifying example: test_for_nesting_limitation(
E           data={'': {}},
E       )

test_encoders.py:24: AssertionError
========================================= short test summary info ==========================================
FAILED test_encoders.py::test_for_nesting_limitation - AssertionError: assert {'': '{}'} == {'': {}}

好的,这出乎意料。现在来解读一下这个测试出了什么问题。简而言之,这个测试表明编码/解码函数对嵌套字典处理不正确。

  • 反例。 最重要的线索在最底部。Hypothesis 告诉了我们导致代码中断的确切输入。
test_for_nesting_limitation(
    data={'': {}},
)
  • 输入是一个字典,其中键是空字符串,值是空字典。这是一个经典的边界情况,人类开发者很容易忽略。
  • 断言错误: 测试因断言语句失败而告终:

AssertionError: assert {'': '{}'} == {'': {}}

这是问题的核心。进入测试的原始数据是 {'': {}}。而从函数中解码出来的结果是 {'': '{}'}。这表明对于键 '',值是不同的:

  • 在 decoded 中,值是字符串 '{}'。
  • 在 data 中,值是字典 {}。

字符串不等于字典,因此断言 assert decoded == data 的结果为 False,测试失败。

逐步追踪 Bug

encode_dict_to_querystring 函数使用了 urllib.parse.urlencode。当 urlencode 遇到一个字典类型的值(例如 {})时,它不知道如何处理,因此只是简单地将其转换为字符串表示形式('{}')。

关于值原始类型(它是一个字典)的信息永久丢失了。

当 decode_querystring_to_dict 函数读回数据时,它正确地将值解码为字符串 '{}'。它无法得知该值最初是一个字典。

解决方案:将嵌套值编码为 JSON 字符串

解决方案很简单:

  1. 编码。 在进行 URL 编码之前,检查字典中的每个值。如果某个值是字典或列表,则首先将其转换为 JSON 字符串。
  2. 解码。 在 URL 解码之后,检查每个值。如果某个值看起来像 JSON 字符串(例如,以 { 或 [ 开头),则将其解析回 Python 对象。
  3. 使测试更全面。 我们的 @given 装饰器将变得更复杂。简单来说,它告诉 Hypothesis 生成的字典可以包含其他字典作为值,从而允许任意深度的嵌套数据结构。例如:
  • 一个简单的扁平字典:{'name': 'Alice', 'city': 'London'}
  • 一个一级嵌套字典:{'user': {'id': '123', 'name': 'Tom'}}
  • 一个二级嵌套字典:{'config': {'database': {'host': 'localhost'}}}
  • 以此类推……

下面是修复后的代码。

# test_encoders.py

from my_encoders import encode_dict_to_querystring, decode_querystring_to_dict
from hypothesis import given, strategies as st

# =========================================================================
# 测试 1:此测试证明嵌套逻辑是正确的。
# 它使用只生成字符串的策略,因此无需担心类型转换。此测试将通过。
# =========================================================================
@given(data=st.recursive(
    st.dictionaries(st.text(), st.text()),
    lambda children: st.dictionaries(st.text(), children)
))
def test_roundtrip_preserves_nested_structure(data):
    """属性:编码/解码的往返过程应保留嵌套结构。"""
    encoded = encode_dict_to_querystring(data)
    decoded = decode_querystring_to_dict(encoded)
    assert decoded == data

# =========================================================================
# 测试 2:此测试证明对于简单的扁平字典,类型转换逻辑是正确的。此测试也将通过。
# =========================================================================
@given(data=st.dictionaries(st.text(), st.integers() | st.text()))
def test_roundtrip_stringifies_simple_values(data):
    """
    属性:往返过程应将简单值(如整数)转换为字符串。
    """
    encoded = encode_dict_to_querystring(data)
    decoded = decode_querystring_to_dict(encoded)

    # 创建预期模型:一个带有字符串化值的字典。
    expected_data = {k: str(v) for k, v in data.items()}
    assert decoded == expected_data

现在,如果重新运行测试,将得到以下结果:

(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item

test_encoders.py .                                                                                   [100%]

============================================ 1 passed in 0.16s =============================================

这里所经历的,是一个经典的案例,生动展示了使用 Hypothesis 进行测试的巨大价值。原本以为两个简单且无错误的函数,实际却并非如此。

代码示例 3 — 为 Pydantic 模型构建自定义策略

许多实际函数并不仅仅接受简单的字典;它们需要像 Pydantic 模型这样的结构化对象。Hypothesis 同样可以为这些自定义类型构建生成策略。

在 my_models.py 中定义一个模型。

# my_models.py
from pydantic import BaseModel, Field
from typing import List

class Product(BaseModel):
    id: int = Field(gt=0)
    name: str = Field(min_length=1)
    tags: List[str]
def calculate_shipping_cost(product: Product, weight_kg: float) -> float:
    # 一个有 bug 的运费计算器
    cost = 10.0 + (weight_kg * 1.5)
    if "fragile" in product.tags:
        cost *= 1.5 # 易碎品的额外费用
    if weight_kg > 10:
        cost += 20 # 重型物品的附加费
    # Bug: 如果成本是负数怎么办?
    return cost

现在,在 test_shipping.py 中,将构建一个策略来生成 Product 实例,并测试这个有缺陷的函数。

# test_shipping.py
from my_models import Product, calculate_shipping_cost
from hypothesis import given, strategies as st

# 为 Product 模型构建策略
product_strategy = st.builds(
    Product,
    id=st.integers(min_value=1),
    name=st.text(min_size=1),
    tags=st.lists(st.sampled_from(["electronics", "books", "fragile", "clothing"]))
)
@given(
    product=product_strategy,
    weight_kg=st.floats(min_value=-10, max_value=100, allow_nan=False, allow_infinity=False)
)
def test_shipping_cost_is_always_positive(product, weight_kg):
    """属性:运费不应为负数。"""
    cost = calculate_shipping_cost(product, weight_kg)
    assert cost >= 0

测试输出结果如何?

(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_shipping.py
========================================================= test session starts ==========================================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item

test_shipping.py F

=============================================================== FAILURES ===============================================================
________________________________________________ test_shipping_cost_is_always_positive _________________________________________________

    @given(
>       product=product_strategy,
                   ^^^
        weight_kg=st.floats(min_value=-10, max_value=100, allow_nan=False, allow_infinity=False)
    )

test_shipping.py:13:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

product = Product(id=1, name='0', tags=[]), weight_kg = -7.0

    @given(
        product=product_strategy,
        weight_kg=st.floats(min_value=-10, max_value=100, allow_nan=False, allow_infinity=False)
    )
    def test_shipping_cost_is_always_positive(product, weight_kg):
        """属性:运费不应为负数。"""
        cost = calculate_shipping_cost(product, weight_kg)
>       assert cost >= 0
E       assert -0.5 >= 0
E       Falsifying example: test_shipping_cost_is_always_positive(
E           product=Product(id=1, name='0', tags=[]),
E           weight_kg=-7.0,
E       )

test_shipping.py:19: AssertionError
======================================================= short test summary info ========================================================
FAILED test_shipping.py::test_shipping_cost_is_always_positive - assert -0.5 >= 0
========================================================== 1 failed in 0.12s ===========================================================

当使用 pytest 运行此测试时,Hypothesis 会迅速找到一个反例:一个具有负数 weight_kg 的产品可能导致负数的运费。这可能是一个开发者未曾考虑到的边界情况,但 Hypothesis 自动发现了它。

代码示例 4 — 测试有状态类

Hypothesis 不仅可以测试纯函数。它还可以通过生成一系列方法调用来测试具有内部状态的类,以尝试发现其潜在问题。现在,将测试一个简单的自定义 LimitedCache 类。

my_cache.py

# my_cache.py
class LimitedCache:
    def __init__(self, capacity: int):
        if capacity <= 0:
            raise ValueError("容量必须是正数")
        self._cache = {}
        self._capacity = capacity
        # Bug: 这可能应该是一个 deque 或有序字典,以便正确实现 LRU
        self._keys_in_order = []

    def put(self, key, value):
        if key not in self._cache and len(self._cache) >= self._capacity:
            # 驱逐最旧的项
            key_to_evict = self._keys_in_order.pop(0)
            del self._cache[key_to_evict]

        if key not in self._keys_in_order:
            self._keys_in_order.append(key)
        self._cache[key] = value

    def get(self, key):
        return self._cache.get(key)

    @property
    def size(self):
        return len(self._cache)

这个缓存的驱逐策略存在几个潜在的错误。现在,将使用 Hypothesis 基于规则的状态机来测试它。这种状态机旨在通过生成随机的方法调用序列来测试具有内部状态的对象,以识别仅在特定交互后才出现的错误。

创建文件 test_cache.py。

from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, rule, precondition
from my_cache import LimitedCache

class CacheMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.cache = LimitedCache(capacity=3)

    # 此规则添加 3 个初始项以填满缓存
    @rule(
        k1=st.just('a'), k2=st.just('b'), k3=st.just('c'),
        v1=st.integers(), v2=st.integers(), v3=st.integers()
    )
    def fill_cache(self, k1, v1, k2, v2, k3, v3):
        self.cache.put(k1, v1)
        self.cache.put(k2, v2)
        self.cache.put(k3, v3)

    # 此规则只能在缓存被填满后运行。
    # 它测试 LRU 与 FIFO 的核心逻辑。
    @precondition(lambda self: self.cache.size == 3)
    @rule()
    def test_update_behavior(self):
        """
        属性:更新最旧的项 ('a') 应该使其成为最新的项,
        因此下一次驱逐应该移除次旧的项 ('b')。
        我们有 bug 的 FIFO 缓存无论如何都会错误地移除 'a'。
        """
        # 此时,keys_in_order 为 ['a', 'b', 'c']。
        # 'a' 是最旧的。

        # 通过更新再次“使用” 'a'。在一个正确的 LRU 缓存中,
        # 这将使 'a' 成为最近使用的项。
        self.cache.put('a', 999)

        # 现在,添加一个新键,这将强制发生驱逐。
        self.cache.put('d', 4)

        # 一个正确的 LRU 缓存会驱逐 'b'。
        # 我们有 bug 的 FIFO 缓存会驱逐 'a'。
        # 此断言检查 'a' 的状态。
        # 在我们有 bug 的缓存中,get('a') 将为 None,因此这将失败。
        assert self.cache.get('a') is not None, "项 'a' 被错误地驱逐了"

# 这告诉 pytest 运行状态机测试
TestCache = CacheMachine.TestCase

Hypothesis 将生成一系列冗长的 put 和 get 操作序列。它会迅速识别出导致缓存大小超出其容量的 put 操作序列,或者其驱逐行为与我们模型不同的情况,从而揭示实现中的错误。

(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_cache.py
========================================================= test session starts ==========================================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item

test_cache.py F

=============================================================== FAILURES ===============================================================
__________________________________________________________ TestCache.runTest ___________________________________________________________

self = <hypothesis.stateful.CacheMachine.TestCase testMethod=runTest>

    def runTest(self):
>       run_state_machine_as_test(cls, settings=self.settings)

../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:476:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:258: in run_state_machine_as_test
    state_machine_test(state_machine_factory)
../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:115: in run_state_machine
    @given(st.data())
               ^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = CacheMachine({})

    @precondition(lambda self: self.cache.size == 3)
    @rule()
    def test_update_behavior(self):
        """
        属性:更新最旧的项 ('a') 应该使其成为最新的项,
        因此下一次驱逐应该移除次旧的项 ('b')。
        我们有 bug 的 FIFO 缓存无论如何都会错误地移除 'a'。
        """
        # 此时,keys_in_order 为 ['a', 'b', 'c']。
        # 'a' 是最旧的。

        # 通过更新再次“使用” 'a'。在一个正确的 LRU 缓存中,
        # 这将使 'a' 成为最近使用的项。
        self.cache.put('a', 999)

        # 现在,添加一个新键,这将强制发生驱逐。
        self.cache.put('d', 4)

        # 一个正确的 LRU 缓存会驱逐 'b'。
        # 我们有 bug 的 FIFO 缓存会驱逐 'a'。
        # 此断言检查 'a' 的状态。
        # 在我们有 bug 的缓存中,get('a') 将为 None,因此这将失败。
>       assert self.cache.get('a') is not None, "项 'a' 被错误地驱逐了"
E       AssertionError: Item 'a' was incorrectly evicted
E       assert None is not None
E        +  where None = get('a')
E        +    where get = <my_cache.LimitedCache object at 0x7f0debd1da90>.get
E        +      where <my_cache.LimitedCache object at 0x7f0debd1da90> = CacheMachine({}).cache
E       Falsifying example:
E       state = CacheMachine()
E       state.fill_cache(k1='a', k2='b', k3='c', v1=0, v2=0, v3=0)
E       state.test_update_behavior()
E       state.teardown()

test_cache.py:44: AssertionError
======================================================= short test summary info ========================================================
FAILED test_cache.py::TestCache::runTest - AssertionError: Item 'a' was incorrectly evicted
========================================================== 1 failed in 0.20s ===========================================================

上述输出突显了代码中的一个错误。简单来说,这个输出表明缓存不是一个真正的“最近最少使用”(LRU)缓存。它存在以下显著缺陷:

当更新缓存中已有的项时,缓存未能记住该项现在是“最新”的。它仍然将其视为最旧的,因此该项会被过早地从缓存中逐出。

代码示例 5 — 对照更简单的参考实现进行测试

最后一个示例将探讨一种典型情况。开发者经常会编写一些函数,旨在替换旧的、较慢但功能完全正确的函数。新函数对于相同的输入必须产生与旧函数相同的输出。Hypothesis 可以极大简化在这方面的测试工作。

假设有一个简单的函数 sum_list_simple,以及一个带有 bug 的新版“优化”函数 sum_list_fast。

my_sums.py

# my_sums.py
def sum_list_simple(data: list[int]) -> int:
    # 这是我们简单、正确的参考实现
    return sum(data)

def sum_list_fast(data: list[int]) -> int:
    # 一个带有 bug 的新版“快速”实现(例如,大数时的整数溢出)
    # 或者,在这个例子中,一个简单的错误。
    total = 0
    for x in data:
        # Bug: 这里应该是 +=
        total = x
    return total

test_sums.py

# test_sums.py
from my_sums import sum_list_simple, sum_list_fast
from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_fast_sum_matches_simple_sum(data):
    """
    属性:新的快速函数的结果应始终与简单的参考函数的结果匹配。
    """
    assert sum_list_fast(data) == sum_list_simple(data)

Hypothesis 会迅速发现,对于任何包含多个元素的列表,新函数都会失败。现在来验证一下。

(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_sums.py
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item

test_my_sums.py F

================================================= FAILURES =================================================
_____________________________________ test_fast_sum_matches_simple_sum _____________________________________

    @given(st.lists(st.integers()))
>   def test_fast_sum_matches_simple_sum(data):
                   ^^^

test_my_sums.py:6:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

data = [1, 0]

    @given(st.lists(st.integers()))
    def test_fast_sum_matches_simple_sum(data):
        """
        属性:新的快速函数的结果应始终与简单的参考函数的结果匹配。
        """
>       assert sum_list_fast(data) == sum_list_simple(data)
E       assert 0 == 1
E        +  where 0 = sum_list_fast([1, 0])
E        +  and   1 = sum_list_simple([1, 0])
E       Falsifying example: test_fast_sum_matches_simple_sum(
E           data=[1, 0],
E       )

test_my_sums.py:11: AssertionError
========================================= short test summary info ==========================================
FAILED test_my_sums.py::test_fast_sum_matches_simple_sum - assert 0 == 1
============================================ 1 failed in 0.17s =============================================

因此,测试失败了,因为“快速”求和函数对于输入列表 [1, 0] 给出了错误的答案(0),而“简单”求和函数提供的正确答案是 1。现在既然已经知道了问题所在,就可以着手修复它。

总结

本文深入探讨了使用 Hypothesis 进行基于属性的测试,超越了简单的示例,展示了如何将其应用于实际的测试挑战。通过定义代码的不变量,可以发现传统测试可能遗漏的细微错误。本文学习了如何:

  • 测试“往返”属性,并了解更复杂的数据策略如何揭示代码中的局限性。
  • 构建自定义策略来生成复杂 Pydantic 模型的实例,用于测试业务逻辑。
  • 使用基于规则的状态机 (RuleBasedStateMachine) 通过生成一系列方法调用来测试有状态类的行为。
  • 通过对照更直接、已知正确的参考实现来验证复杂且经过优化的函数。

将基于属性的测试添加到开发者的工具集中,并不会完全取代现有的所有测试。然而,它将极大地增强现有测试,促使开发者更清晰地思考代码的契约,并对代码的正确性拥有更高程度的信心。鼓励开发者从代码库中选择一个函数或类,思考其基本属性,然后让 Hypothesis 尽力去证明这些属性的错误。通过这一过程,开发者将成为更优秀的程序员。

本文仅触及了 Hypothesis 在测试方面能力的冰山一角。欲了解更多信息,请参阅其官方文档,可通过以下链接获取。

TAGGED:HypothesisPython测试基于属性测试编程软件测试
Share This Article
Email Copy Link Print
Previous Article TestGorilla报告图表 1 深度洞察:2025年技能招聘报告揭示人才市场变革与AI新机遇
Next Article 如何打造雇主青睐的机器学习项目:告别平庸,斩获Offer!
Leave a Comment

发表回复 取消回复

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

最新内容
20251202135921634.jpg
英伟达20亿美元投资新思科技,AI芯片设计革命加速
科技
20251202130505639.jpg
乌克兰国家AI模型选定谷歌Gemma,打造主权人工智能
科技
20251202121525971.jpg
中国开源AI新突破:DeepSeek V3.2模型性能比肩GPT-5
科技
20251202112744609.jpg
马斯克预言:AI三年内解决美国债务危机,可信吗?
科技

相关内容

编程与工具

Python数据可视化:核心编程基础回顾与应用

2025年10月26日
哥尼斯堡七桥问题图示
编程与工具

欧拉旋律:图算法在算法音乐创作中的应用与Python实践

2025年9月29日
图片:在几分钟内构建有用的Streamlit仪表板的5个技巧
编程与工具

高效Streamlit仪表板:5个实用技巧助你快速上手

2025年9月21日
图2:懒惰数据科学家的时间序列预测指南
编程与工具

懒惰数据科学家的时间序列预测指南:Python与自动化工具的高效实践

2025年9月21日
Show More
前途科技

前途科技是一个致力于提供全球最新科技资讯的专业网站。我们以实时更新的方式,为用户呈现来自世界各地的科技新闻和深度分析,涵盖从技术创新到企业发展等多方面内容。专注于为用户提供高质量的科技创业新闻和行业动态。

分类

  • AI
  • 初创
  • 学习中心

快速链接

  • 阅读历史
  • 我的关注
  • 我的收藏

Copyright © 2025 AccessPath.com, 前途国际科技咨询(北京)有限公司,版权所有。 | 京ICP备17045010号-1 | 京公网安备 11010502033860号

前途科技
Username or Email Address
Password

Lost your password?

Not a member? Sign Up