- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
Hypothesis 通常被视为一个属性测试(property-based testing)库,但从实现角度看,它首先是一个数据构造与探索引擎:生成数据、提出假设、证明或证伪假设,测试库只是构建在其上的一层薄壳。本文基于 Hypothesis 官方技术博客《Exploring Voting Systems with Hypothesis》,完整演示如何借助
find函数把 Hypothesis 当作交互式数据探索工具,用来在两个真实的投票算法(Plurality 与 IRV)之间自动寻找结果不一致的选举反例;读完本文,你将掌握@st.composite、st.permutations、st.lists、st.integers的组合用法,理解find的底层运行机制,并能把这套"策略 + 谓词 + 最小反例"的方法迁移到自己的探索式开发场景中。
从测试库到数据探索工具:理解 Hypothesis 的双重身份
博客作者 drmaciver 在文中开门见山地指出一个常被忽略的事实:
Hypothesis is, of course, a library for writing tests. But from animplementationpoint of view this is hardly noticeable. Really it's a library for constructing and exploring data and using it to prove or disprove hypotheses about it. It then has a small testing library built on top of it.
也就是说,Hypothesis 的绝大多数用户把它当作测试库使用,这也是项目开发的重心所在;但借助find函数,你完全可以把它当作一个交互式数据探索器:给定一个数据生成策略(strategy)和一个判定条件(condition),它会自动找出满足该条件的最小数据示例。
从当前仓库源码可以印证这一点。find的完整实现位于 hypothesis/src/hypothesis/core.py#L2428-L2476,其核心语义在 docstring 中定义得非常明确:
Returns the minimal value from the given strategy
specifierthat matches the predicate functioncondition.
即"返回给定策略中满足谓词函数的最小值"。它的默认配置也体现了"探索"而非"严格测试"的取向:默认使用Settings(max_examples=2000)最多尝试 2000 个示例,并且自动suppress_health_check=list(HealthCheck)抑制健康检查、report_multiple_bugs=False关闭多 bug 报告,因为它只关心能否找到第一个符合条件的值。
正是这种"构造数据 → 验证谓词 → 收缩到最小反例"的循环,让find成为回答"I wonder if...?"这类探索性问题的利器。
探索对象:单一获胜者偏好投票制
为了让探索有一个具体落点,文章聚焦于单一获胜者偏好投票制(single winner preferential voting systems):
- 有一组候选人(candidates);
- 每位选民给出一个完整排序,从最喜爱到最不喜爱;
- 投票系统根据全体选票选出一名获胜者。
文中约定的 Python 接口非常简洁:每个投票系统是一个函数,接收一个 election(选票列表),每张选票是一个把候选人按偏好排序的列表,返回"无歧义获胜者"或None(表示平局):
def plurality_winner(election): counts = Counter(vote[0] for vote in election) alternatives = candidates_for_election(election) winning_score = max(counts.values()) winners = [c for c, v in counts.items() if v == winning_score] if len(winners) > 1: return None else: return winners[0]这是Plurality 投票制(多数人理解的"普通投票"):获得最多第一偏好票的候选人获胜;若最高票数被多人并列,则返回None表示平局。
另一个重点考察的投票制是IRV(Instant Runoff Voting,即时决选投票;在英国政治语境下常被称为 Alternative Vote,替代投票)。IRV 采用多轮淘汰机制,直到剩下唯一候选人:
def irv_winner(election): candidates = candidates_for_election(election) while len(candidates) > 1: scores = Counter() for vote in election: for c in vote: if c in candidates: scores[c] += 1 break losing_score = min(scores[c] for c in candidates) candidates = [c for c in candidates if scores[c] > losing_score] if not candidates: return None else: return candidates[0]IRV 的每轮逻辑是:对每个仍在竞争的候选人统计"有多少选民把他排在剩余候选人中的第一位",联合最低分的候选人一起淘汰。循环结束时可能剩下 0 个或 1 个候选人:0 个意味着在某轮所有候选人并列最低分,判定为平局(draw);1 个即为获胜者(victory)。
直觉上,这两种制度不可能在所有选举中都给出相同答案——但如何手工构造一个让二者分道扬镳的例子并不直观。这正是 Hypothesis 登场的地方。
用 Hypothesis 构造选举生成器:@st.composite
首先需要定义"随机生成一场选举"的策略。文章使用@st.composite装饰器把任意一个"从数据流里取值"的函数转化为策略:
import hypothesis.strategies as st @st.composite def election(draw): candidates = list(range(draw(st.integers(2, 10)))) return draw(st.lists(st.permutations(candidates), min_size=1))逐行拆解这个策略:
st.integers(2, 10)先画出一个 2 到 10 之间的整数,list(range(...))将其转成候选人的整数编号列表。注意range的右端点不包含,因此实际候选人数是2 到 9 人。候选人本身用整数编号即可——只要彼此可区分,具体是什么并不重要,用整数最简洁。st.permutations(candidates)生成候选人的一种随机排列,对应一张"完整偏好排序"选票。st.lists(..., min_size=1)把多张这样的选票聚合成一场选举,min_size=1保证至少有一位选民。
也就是说,这个策略生成的选举是:由 2~9 名候选人、1 张及以上随机偏好排列的选票构成的列表。
从当前仓库源码可以进一步印证每个策略的语义:
st.integers定义于 hypothesis/src/hypothesis/strategies/_internal/numbers.py#L121-L125,支持min_value/max_value参数约束取值区间;st.lists定义于 hypothesis/src/hypothesis/strategies/_internal/core.py#L303-L360,docstring 明确其行为:列表长度落在[min_size, max_size]区间内(任一方向为None则无边界),并且"示例收缩时会尝试移除列表元素、同时收缩每个元素";st.permutations定义于 hypothesis/src/hypothesis/strategies/_internal/core.py#L2021-L2033,docstring 说明其收缩方向:"示例收缩时会尝试更接近values的原始顺序"——这保证了find找到反例后能高效地把它缩到更小、更易读;@st.composite装饰器定义于 hypothesis/src/hypothesis/strategies/_internal/core.py#L2177-L2186,它把接收draw回调的函数包装成策略,是编写复合数据生成器最惯用的方式。
顺带一提,election策略本身也有对应的真实测试覆盖,例如仓库中的 hypothesis/tests/cover/test_find.py 对find的行为做了系统性验证。
写出你感兴趣的谓词:differing_without_ties
有了数据生成器之后,接下来定义"我们要找什么"——一个谓词函数,接受一场选举并返回是否感兴趣:
def differing_without_ties(election): irv = irv_winner(election) if irv is None: return False plurality = plurality_winner(election) if plurality is None: return False return irv != plurality这个谓词表达的语义是:Plurality 与 IRV 都没有出现平局,但两者选出的获胜者不同。这就是我们要让find去寻找的"有趣"数据——一个能证明两种投票制度会得出不同结果的选举反例。
注意这里对平局的排除是有代价的:如文章后续所说,正是因为坚持"无平局"这个额外约束,最终找到的例子才会偏大。如果允许任意打破平局(例如偏好编号较小的候选人),可以找到小得多的反例。
在控制台运行 find:自动收获一个反例
把策略和谓词交给find,在交互式控制台里即可运行:
>>> from hypothesis import find >>> import voting as v >>> distinct = find(v.election(), v.differing_without_ties) >>> distinct [[0, 1, 2], [0, 1, 2], [1, 0, 2], [2, 1, 0], [0, 1, 2], [0, 1, 2], [1, 0, 2], [1, 0, 2], [2, 1, 0]](示例中voting是包含前文plurality_winner、irv_winner等函数的一个模块,原文命名为voting。)
Hypothesis 很快找到了这样一场选举:3 名候选人(0、1、2),9 张选票。接下来验证这场选举确实让两种制度产生了分歧:
>>> v.irv_winner(distinct) 1 >>> v.plurality_winner(distinct) 0IRV 的获胜者是候选人 1,Plurality 的获胜者是候选人 0——两种制度在同一场选举上给出了不同结果,反例成立。
文章的观察也值得记录:
- 例子偏大的主要原因是"无平局"的硬约束;若允许任意打破平局,能找到更小的例子;
- 不同次运行中,Hypothesis 有时会找到一个略小、但包含 4 名候选人的选举——这说明
find的搜索结果具有一定的运行间差异(这也与它默认基于随机种子探索有关)。
find 的工作原理:源码视角
find之所以能"自动找到最小反例",其底层机制在 hypothesis/src/hypothesis/core.py#L2428-L2476 中一目了然:
def find( specifier: SearchStrategy[Ex], condition: Callable[[Any], bool], *, settings: Settings | None = None, random: Random | None = None, database_key: bytes | None = None, ) -> Ex:关键实现细节:
- 默认设置:未传入
settings时使用Settings(max_examples=2000),并强制suppress_health_check=list(HealthCheck)、report_multiple_bugs=False; - 数据库键:若未指定
database_key,会用function_digest(condition)对谓词函数求摘要作为数据库键(源码注释提醒该键并不保证唯一); - 基于 @given 运行:
find内部把谓词包装进一个@given(specifier)装饰的测试函数,命中条件时抛出内部Found异常记录当前值并终止搜索;test()结束后返回记录的值; - 找不到时的行为:若 2000 个示例(或收缩后的整个探索过程)内没有命中,则抛出
NoSuchExample异常并附上谓词的描述。
这也是仓库 hypothesis/docs/changelog.rst 中记录的一条演进脉络:find()曾在 4.28.0 版本(2019-07-11)被建议弃用——官方认为想"随便拿一个例子"时.example()更合适、想要"最小例子"时可以用@given;随后 4.47.5 版本(2019-11-28)又把find()重构为基于@given实现,以复用更多公共代码。直到当前仓库版本,find仍保留在core.py中可直接使用,本文介绍的用法依然成立。
更多探索方向
投票制度还有很多值得探索的性质,原文为感兴趣的读者留了三个练习:
- 寻找 Condorcet 循环:构造一场存在"投票悖论"的选举——例如选民整体上偏好 A 胜过 B、偏好 B 胜过 C、却又偏好 C 胜过 A,导致多数偏好无法形成一个明确的传递顺序;
- 比较多数偏好与获胜者:寻找"多数选民更偏好 Plurality 获胜者胜过 IRV 获胜者"以及相反方向的选举,观察两种制度在"尊重多数意愿"上的差异;
- 用 @given 替代 find 写性质测试:把经典的选举制度评判准则(如单峰性、单调性、独立性等)形式化为不变量,用
@given写成常规测试,让 Hypothesis 大规模随机验证而非只找一个反例。
这三条路线恰好勾勒出find与@given的分工:前者适合交互式、一次性的探索;后者适合可重复、可回归的自动化验证。
超越选举:探索式开发中的 Hypothesis
文章的收尾把视野拉回到工程实践:开发过程往往是一连串小实验,测试是执行这些实验的好方式,但有时你只是想回答一个探索性的"I wonder if...?"问题——"这两种算法会不会在某些输入上结果不一致?""这个约束会不会让某些合法输入无法生成?"——这种时候,把 Hypothesis 的生成与收缩能力直接带到 REPL 里,往往比先搭好一整套测试框架高效得多。
本文演示的"@st.composite定义数据形状 → 谓词表达探索目标 →find返回最小反例"三步法,可以原样迁移到解析器、序列化器、状态机、数值计算等任何"输入空间巨大、直觉难以穷举"的领域。当你下一次产生"我很好奇……"的念头时,不妨让 Hypothesis 替你去找答案。
- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
相关推荐
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考