用 Hypothesis @given 与 lists 测试去重函数并自动缩小反例
问题场景
去重函数看起来简单,但很容易在边界上出错:空列表、全是重复值、混入未在输入中出现的元素、返回结果内部仍有重复等。与其只写几个手写用例,不如把去重函数的核心契约写成属性:输出中没有重复元素,输出中的每个元素都来自输入,且输出长度不应超过输入长度。Hypothesis 的 @given 与 lists 策略可以自动生成大量列表输入,并在断言失败时把输入缩小到更简单的反例,方便定位问题。
可运行的最小示例
先准备环境:
pip install hypothesis pytest
实现一个保持首次出现顺序的去重函数:
# dedupe.py
def dedupe_preserve_order(seq):
seen = set()
result = []
for item in seq:
if item not in seen:
seen.add(item)
result.append(item)
return result
对应的属性测试文件如下。这里使用 st.lists(st.integers()) 生成可哈希的整数列表,因此可以直接用 set 判断重复与来源:
# test_dedupe.py
from hypothesis import given, strategies as st
from dedupe import dedupe_preserve_order
@given(st.lists(st.integers()))
def test_dedupe_properties(seq):
result = dedupe_preserve_order(seq)
assert len(result) == len(set(result)), '输出存在重复元素'
assert set(result).issubset(set(seq)), '输出包含输入中不存在的元素'
assert len(result) <= len(seq), '输出长度不应超过输入'
运行方式:
pytest -q test_dedupe.py
pytest 会自动发现 test_*.py 文件。Hypothesis 与 pytest 集成良好,通常不需要额外插件。上面的测试如果实现正确,会正常通过;如果实现错误,Hypothesis 会报告失败用例。
失败时自动缩小反例
为了观察缩小行为,可以故意写一个错误实现:空列表也插入 0,从而破坏“输出元素均来自输入”的属性。
# test_dedupe_buggy.py
from hypothesis import given, strategies as st
def dedupe_buggy(seq):
return [0] + list(set(seq))
@given(st.lists(st.integers()))
def test_dedupe_buggy(seq):
result = dedupe_buggy(seq)
assert set(result).issubset(set(seq)), '输出包含输入中不存在的元素'
运行:
pytest -q test_dedupe_buggy.py
当 seq 为空列表时,dedupe_buggy([]) 返回 [0],而 0 并不来自输入,因此断言失败。Hypothesis 在发现失败后不会只停留在随机的大列表上,而会尝试更简单的输入,最终通常会把反例缩小到空列表或只包含少量元素的列表。若错误实现是把输入原样返回,则最小反例往往会缩小到类似 [0, 0] 这样的重复元素列表。这个过程就是 shrinking,它让失败原因更直观。
还可以用 @example 固定补充边界用例,与自动生成策略配合使用:
from hypothesis import example, given, strategies as st
from dedupe import dedupe_preserve_order
@given(st.lists(st.integers()))
@example([])
@example([1, 1, 2, 2])
def test_dedupe_properties_with_examples(seq):
result = dedupe_preserve_order(seq)
assert len(result) == len(set(result)), '输出存在重复元素'
assert set(result).issubset(set(seq)), '输出包含输入中不存在的元素'
边界与限制
第一,属性测试不是形式证明。Hypothesis 默认只尝试有限数量的例子,可以通过 @settings(max_examples=...) 增加尝试次数,但会增加运行时间。
第二,st.lists(st.integers()) 适合可哈希元素。如果元素不可哈希,例如列表或字典,set(result) 和 set(seq) 会失败。此时需要调整策略,例如使用 st.lists(st.tuples(st.integers(), st.integers())) 生成可哈希元组,或把来源断言改为 all(item in seq for item in result),但要注意后者是线性查找,列表很大时会变慢。
第三,浮点特殊值需要谨慎。NaN 在集合中的行为容易让人困惑,可能影响重复判断。如果去重函数面向浮点数,建议明确语义,必要时避开 NaN 或单独处理。
第四,列表长度默认有上限。需要覆盖超长列表时,可以使用 st.lists(st.integers(), max_size=...) 控制生成范围,同时注意内存与执行时间。
第五,本示例只断言“无重复”和“元素来自输入”,没有断言保持原顺序。如果顺序也是契约的一部分,可以额外断言首次出现顺序,例如 result == list(dict.fromkeys(seq)),但同样要求元素可哈希且语义明确。
把这些属性测试与 pytest 集成后,去重函数的边界问题会更容易暴露,失败时也能借助 Hypothesis 的缩小机制快速找到最小反例。
- 点赞
- 收藏
- 关注作者
评论(0)