☰
Hypothesis API 风格指南:为属性测试库设计一致、易用的策略 API
2026/9/25 8:58:53 网站建设 项目流程
  • 测试
  • 开发工具

【免费下载链接】hypothesis

The property-based testing library for Python

项目地址:https://gitcode.com/gh_mirrors/hy/hypothesis
点击查看免费下载

Hypothesis 是一个基于属性的 Python 测试库(property-based testing library),其核心价值在于让开发者用少量代码表达"测试意图",再由引擎自动生成大量样本并完成最小化。为了让不断增长的策略(strategy)API 保持一致的手感,Hypothesis 团队维护了一份名为House API Style的内部风格指南,也就是本仓库中的 guides/api-style.rst。它主要面向两类读者:一是为 Hypothesis 贡献新策略的维护者,二是开发第三方扩展(如hypothesis.extra模块)的库作者。读完本文,你将理解 Hypothesis 策略 API 的设计原则、参数约定、延迟求值机制,以及哪些历史 API 被视为"违规样板"需要规避。

这份指南并非代码风格规范,而是一份API 设计规范——它回答的是"什么样的公开接口看起来像 Hypothesis 的接口"这一问题。

通用准则:Hypothesis API 的整体气质

原文档在General Guidelines一节中给出了五条贯穿所有 API 的顶层原则,它们是理解后续所有细节的总纲:

  1. extras 模块的一致性优先:编写hypothesis.extra扩展时,与 Hypothesis 自身的一致性要优先于与所集成第三方库的一致性。也就是说,即便某个库(如 numpy、django)有自己的惯用风格,一旦进入 Hypothesis 的扩展模块,也要按 Hypothesis 的规矩来。
  2. 公开 API 绝对禁止子类化:用户不应通过继承SearchStrategy之类的方式来自定义行为,扩展点必须是组合式的。
  3. 不过分追求"Pythonic":如果某个 API 让普通 Python 用户觉得奇怪,团队会尝试找出一个同样喜欢但没那么怪异的替代方案——"Pythonic"是参考项,不是绝对标准。
  4. 第三方依赖必须隔离在hypothesis.extra中:任何引入第三方包依赖的代码都应放入hypothesis.extra模块,核心库保持零重依赖。
  5. 复杂度不能转嫁给用户:一个易用的 API 比一个简单的实现更重要。即"实现可以复杂,接口必须简单"。

从源码结构可以印证第 4 条:本仓库的 hypothesis/src/hypothesis/extra 目录下按django、pandas、numpy、lark、redis、pytz、dateutil等第三方库分别组织模块,核心的hypothesis.strategies则完全不依赖这些库。

策略的定位:配方与取值范围的中间地带

针对策略本身,指南给出了三条设计要领:

  • 策略函数应介于"构建值的配方"与"合法值的取值范围"之间。它既要能描述"如何构造",也要能表达"什么样的值合法",但不应越界去规定值的统计分布。
  • 参数只说明如何产生合法值,不暗示统计性质。分布提示(distribution hints)不属于策略参数的职责。
  • 策略应尽量抹平底层类型的非均匀性。指南举例:hypothesis.extra.numpy为 numpy 在 object 数组上的怪异行为做了大量 workaround,让用户感知不到底层差异。
  • 默认行为应尽量开放:策略默认应允许生成它能支持的任何样本。例外只有极少数"几乎不会感兴趣的失败输入",目前仅有两处:st.text()默认排除非 UTF-8 字符,以及 numpy 数组默认排除零维度或零长度边。而这些例外都必须让用户"轻松地显式选择加入"(opting in should be trivial)。

参数处理:验证、默认值与关键字专属

参数处理是 Hypothesis 风格中最具辨识度的部分,原文列了八条细则,每一条都可以在源码中找到对应实现:

1. 尽可能彻底地验证参数

参数必须被验证到最大程度:非法参数应以InvalidArgument错误拒绝,而不是让内部异常泄漏给用户。例如 integers() 的源码开头就是一连串校验:

def integers( min_value: int | None = None, max_value: int | None = None, ) -> SearchStrategy[int]: check_valid_bound(min_value, "min_value") check_valid_bound(max_value, "max_value") check_valid_interval(min_value, max_value, "min_value", "max_value") if min_value is not None: if min_value != int(min_value): raise InvalidArgument(...) ...

同样,lists() 在构造策略前会调用check_valid_sizes(min_size, max_size)和check_strategy(elements, "elements"),并对手工unique与unique_by冲突等情况显式抛出InvalidArgument。

2. 大量使用默认参数

只要一个参数有合理的默认值,就应该给默认值。lists()的签名是典型代表:min_size: int = 0、max_size: int | None = None、unique_by=None、unique=False。

3. 集合类型策略的元素策略参数不设默认值

这是一个刻意的例外:lists()、sets()、tuples()等的第一个位置参数elements是必填的,没有默认值。理由很实际——元素策略没有合理的通用默认,强制显式传入能让用户清楚地意识到自己在生成什么。

4. 有默认值的参数应设为 keyword-only

除min_value/max_value之外,带默认值的参数都应是关键字专属参数。lists()的签名完美示范了这一点:elements是唯一的位置参数,其余全部是*之后的 keyword-only。floats()更彻底:

def floats( min_value: Real | None = None, max_value: Real | None = None, *, allow_nan: bool | None = None, allow_infinity: bool | None = None, allow_subnormal: bool | None = None, width: Literal[16, 32, 64] = 64, exclude_min: bool = False, exclude_max: bool = False, ) -> SearchStrategy[float]:

(见 numbers.py)

5.min_value/max_value的默认规则及其例外

对于无界类型(如整数),min_value/max_value默认None(表示无界);对于有界类型(如 datetime),默认应取最小/最大值。floats()是这条规则的显式例外,因为浮点需要特殊处理无穷大(infinity)和 NaN:源码中allow_nan的默认值由边界决定——allow_nan = bool(min_value is None and max_value is None),且显式设置allow_nan=True的同时给出边界会直接抛InvalidArgument。

6. 交互式参数:默认行为应自动调整

当参数之间存在约束关系(如必须有序、至多一个合法、一个参数限制另一个的范围)时,默认值的行为必须随之自动调整。典型例子是floats():allow_nan与边界参数交互、allow_infinity与双边界交互,源码都做了自动推导与冲突校验。

7. 实际默认值依赖其他参数时,默认参数应为 None

如果某个参数的"最终生效值"取决于其它参数,那么在签名里应写None,由函数体去推导真正使用的值。floats()的allow_nan、allow_infinity、allow_subnormal全部遵循此模式。

8. 参数顺序与"值 vs 策略"的决策

  • 前一到两个参数最可能被位置传参,因此应把最常用、最自然的值放在前面。集合类型的elements放第一位、有序类型的min_value/max_value放前两位,都遵循这一原则。
  • 考虑用户是否想让该参数经常变化:如果用户很可能写some_strategy.flatmap(lambda x: my_new_strategy(argument=x)),那么这个参数就应该直接接收一个策略,而不是一个值。
  • 禁止"值或策略二选一"的参数:如果你倾向于写"传值或传生成该值的策略",请改成只接收策略;用户想传固定值时,用st.just(value)包一层即可。

最后一条来自原文档的警告值得单独强调:当参数组合导致无法生成任何东西时,应raise InvalidArgument,而不是返回nothing()。返回空策略(null strategy)在概念上很优雅,但在组合策略中会导致部分被静默丢弃,从而产生意外地弱化的测试。

函数与参数命名约定

命名方面,原文坦诚"没有真正的一致性",但给出了大致方向:

  • 函数名遵循 Python 标准的snake_case。
  • 针对特定类型的策略通常以该类型的复数形式命名;当类型本身有截断形式(如int、str)时,策略名使用更长的完整形式。这正是integers()、text()而非ints()、str()的原因——从 hypothesis/src/hypothesis/strategies/init.py 的公开导出列表可以清楚看到这套命名体系。
  • 其余策略没有统一的命名惯例。

参数命名则有两条硬性约定,要求跨策略保持一致:

  1. 集合类型:元素策略永远放在最前面,单一元素策略必须叫elements(dictionaries()用keys/values是允许的例外,因为有两个元素策略)。见lists(elements, ...)、sets(elements, ...)的签名。
  2. 有序类型:前两个参数必须是下界和上界,命名为min_value和max_value。这是它们作为唯一"带默认值却仍可位置传参"例外的根本原因。
  3. 集合大小:集合类型必须有min_size/max_size控制尺寸范围,且min_size默认0、max_size默认None(即使内部实际有界,签名上也写None)。

延迟错误:把错误推迟到测试运行时

"延迟错误"(Deferred Errors)是 Hypothesis API 风格中最深刻的一条设计哲学,原文用相当篇幅阐述了它。

机制:错误应在测试运行时抛出,而非定义时

尽可能让函数在测试运行时(典型实现方式是推迟到从策略中draw时才抛出)报错,而不是在策略被调用时就报错。这主要适用于策略函数以及@given自身的一部分错误条件。

原文档指出,这一机制通常由@defines_strategy装饰器自动完成。查看源码 hypothesis/src/hypothesis/strategies/_internal/utils.py,可以看到它正是延迟求值的实现核心:

def defines_strategy( *, force_reusable_values: bool = False, eager: bool | Literal["try"] = False, ) -> Callable[[T], T]: ... @proxies(strategy_definition) def accept(*args, **kwargs): from hypothesis.strategies._internal.lazy import LazyStrategy if eager == "try": try: return strategy_definition(*args, **kwargs) except Exception: pass result = LazyStrategy(strategy_definition, args, kwargs) ...

装饰器默认把策略函数包装进LazyStrategy,即调用策略函数时不真正求值,只在测试中首次绘制(draw)样本时才执行定义函数。eager="try"模式会先尝试立即求值一次,一旦抛异常就回退到懒包装,从而把错误"原样"推迟到测试运行时。

为什么要这样做

原文给出三点核心理由:

  1. 导入期错误难以调试:测试代码在导入阶段报错会让人措手不及。用户天然期望"测试代码的错误表现为测试失败",即使这段代码写在装饰器里,这种期望也不应被打破。
  2. 运行时错误定位更好:弃用警告(deprecation warning)等提示在测试内部发生时能更好地与具体测试绑定——测试运行器常常吞掉导入期的输出,或把它放到奇怪的位置。
  3. 一致性:使用data交互式绘制、flatmap链式组合、@composite组合策略时,策略只有在测试运行阶段才会被求值,错误只能发生在那里。如果有时定义时报错、有时测试时报错,会非常诡异。

一个明确的例外

指南明确说明:目前没有为"错误调用函数"(如非法关键字参数、缺少必填参数导致的TypeError)做延迟化。理论上可以,但那样会让函数签名难以阅读,等于用一种可理解性换另一种可理解性,至今被认为不值得。

第三方策略作者须知

值得注意:@defines_strategy的文档字符串明确写道——第三方策略库作者不需要使用该装饰器,它是 Hypothesis 内部机制,仅用于把策略注册进_all_strategies全局注册表(供文档完备性检查等内部测试使用)。第三方库若想享受延迟求值,可自行参考LazyStrategy的实现模式(位于 hypothesis/src/hypothesis/strategies/_internal/lazy.py)。

从规范推断策略:from_*家族的约定

从某个规格或模式(specification/schema)推断策略的函数,对用户非常方便,同时让"合法输入"和"实际测试的输入"有单一事实来源。约定如下:

  • 命名:这类函数应命名为from_foo(),第一个参数是被推断的对象。本仓库中典型成员包括:st.from_type()、st.from_regex()、extra.lark.from_lark()、extra.numpy.from_dtype()。其余参数一律是可选的 keyword-only。
  • 局部定制路径要平滑:用户不应因为需要一点定制就从零开始。指南表扬from_dtype()是范例:查看 hypothesis/src/hypothesis/extra/numpy.py,其签名在dtype之后提供了alphabet、min_size、max_size、min_value、max_value、allow_nan、allow_infinity等一整套 keyword-only 覆盖参数,"兼容的参数会被透传给被推断的策略函数,不适用的被忽略",从而平滑地定制推断结果的任意局部。
  • repr 应可读:在可行时,返回策略的repr应展示其构造方式,例如repr(from_type(int)) == "integers()",除非必要才使用@st.composite。

作为补充,from_type() 的源码文档展示了类型推断的完整查找顺序,是理解"从规范推断"的最佳例证:

  1. 默认查找表或用户注册表中命中对应策略;
  2. typing模块的类型走特殊逻辑;
  3. 存在子类型时,返回各子类型策略的并集;
  4. 类型的所有必需参数都有注解且非抽象类时,通过st.builds()解析;
  5. 抽象类型按具体子类的并集处理(注意基于继承而非ABCMeta.register)。

用户可以用st.register_type_strategy()注册自定义类型,例如全局排除 NaN、改用带时区的 datetime 策略等。

当前违规清单:历史包袱与改进方向

指南最后诚实列出了当前与上述风格不一致的地方,这些是未来弃用(deprecation)和改进的候选目标:

  1. hypothesis.extra.numpy部分参数"值或策略二选一"——直接违反"参数不应是值或策略"的规则。
  2. hypothesis.extra.numpy假设数组定长——没有min_size/max_size参数。但原文也承认这很可能没问题,因为数组形状更复杂。
  3. hypothesis.stateful是"基于子类化的烂摊子"——原文用 "a great big subclassing based train wreck" 形容,直接违反"公开 API 禁止子类化"的准则,是需要重点重构的历史区域。

这段自述说明:风格指南是规范性的(normative)而非描述性的(descriptive)。旧 API 可能与指南不一致(尤其早期策略),团队也做过失败的实验;当与向后兼容冲突时,向后兼容远比风格一致重要。这是阅读和使用该指南时最重要的心法。

结语:如何应用这份风格指南

对 Hypothesis 贡献者而言,这份指南是提交新策略前必须对照的检查清单:参数是否充分验证、默认值是否合理、带默认的参数是否 keyword-only、元素策略是否置于首位、错误是否被推迟到测试运行时、命名是否符合from_*/复数命名惯例、是否避免"值或策略"二合一参数、无法生成时是否抛InvalidArgument而非返回nothing()。

对第三方扩展作者而言,指南同样适用且被明确鼓励遵循(原文档也欢迎在指南不契合自身领域时联系社区讨论修改)。写出的策略 API 若能"看起来就像 Hypothesis 自己的 API",用户的测试体验会高度一致——而这正是这份 House API Style 存在的全部意义。

进一步阅读建议:

  • 策略的公开入口与导出清单:hypothesis/src/hypothesis/strategies/init.py
  • @defines_strategy与策略缓存实现:hypothesis/src/hypothesis/strategies/_internal/utils.py
  • integers()/floats()的参数校验范例:hypothesis/src/hypothesis/strategies/_internal/numbers.py
  • lists()/from_type()等核心策略:hypothesis/src/hypothesis/strategies/_internal/core.py
  • from_dtype()的平滑定制范例:hypothesis/src/hypothesis/extra/numpy.py
  • 类型推断的测试覆盖:hypothesis/tests/cover/test_type_lookup.py
  • 测试
  • 开发工具

【免费下载链接】hypothesis

The property-based testing library for Python

项目地址:https://gitcode.com/gh_mirrors/hy/hypothesis
点击查看免费下载
上一篇:Bonsai-8B-mlx-1bit与GGUF Q1_0_g128格式对比:哪个更适合你?
下一篇:如何3分钟上手智能爬虫?告别代码的无代码采集工具全解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询