- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
Hypothesis是 Python 生态中广受欢迎的属性测试(property-based testing)库。使用它的一个常见困惑是:如何生成与自身数据模型相匹配的数据——单纯用integers()、text()生成基础类型很容易,但真正要测试的往往是带有约束关系的领域对象(例如"项目名称非空、开始日期必须在结束日期之前")。本文以一篇经典的官方实战示例为基础,结合当前仓库源码,完整演示如何把 Hypothesis 提供的策略工具(text、characters、datetimes、builds、composite、assume)逐层组装起来,最终生成符合业务约束的Project对象,并给出每一步的验证方式。
读完本文,你将掌握:如何用参数约束基础策略、如何用map/filter后处理生成结果、如何为领域模型编写"定制策略",以及builds与composite两种组装方式各自的适用场景。文中所有代码均可在当前仓库对应的源码(hypothesis/src/hypothesis/strategies/_internal/core.py、hypothesis/src/hypothesis/strategies/_internal/datetime.py 等)中找到实现依据。
问题定义:一个需要被生成的领域类
假设我们有如下类:
class Project: def __init__(self, name, start, end): self.name = name self.start = start self.end = end def __repr__(self): return f"Project {self.name} from {self.start.isoformat()} to {self.end.isoformat()}"Project有三个字段:名称(name)、开始日期(start)、结束日期(end)。我们的目标不是写死几个示例,而是让 Hypothesis 能持续、随机地生成满足约束的Project实例用于属性测试。
核心思路是化整为零、逐层组装:先把每个字段所需的子策略构造好,再把这些子策略组合成一个完整的Project策略。下面按"名称 → 日期 → 组装"的顺序推进。
第一步:构造names策略——从基础text()到定制字符集
1.1 从默认text()出发
Hypothesis 的标准文本策略text()可以生成任意 Unicode 字符串:
>>> from hypothesis.strategies import text >>> text().example() '' >>> text().example() '\nŁ昘迥'注意默认text()是允许生成空字符串的。如果项目名称不能为空,加上min_size=1:
>>> text(min_size=1).example() 'w\nC' >>> text(min_size=1).example() 'ሚಃJ»'从源码看,text的签名是text(alphabet=characters(codec="utf-8"), *, min_size=0, max_size=None)(见 core.py)。有两个细节值得注意:
alphabet既可以是一个字符集合(collection),也可以是生成单个字符的策略;- 默认的
alphabet使用characters(codec="utf-8"),即可以覆盖整个 Unicode 范围,但会排除代理区字符(surrogate),因为它们无法用 UTF-8 编码。
1.2 用characters限制字符范围
默认text()可能生成高位 Unicode 字符。虽然一个健壮的系统理应正确处理完整 Unicode 范围,但作为示例,我们先限制一下生成范围。此时需要用到characters策略——它提供了一种灵活的方式来描述"单字符文本"的生成规则:
>>> characters(min_codepoint=1, max_codepoint=1000, exclude_categories=('Cc', 'Cs')).example() '²' >>> characters(min_codepoint=1, max_codepoint=1000, exclude_categories=('Cc', 'Cs')).example() 'E' >>> characters(min_codepoint=1, max_codepoint=1000, exclude_categories=('Cc', 'Cs')).example() '̺'参数含义:
min_codepoint/max_codepoint:限定允许的码点(codepoint)范围。这里把码点 0 排除(它容易被 C 库处理出问题),同时限制在 1000 以内——保留了非 ASCII 字符,但排除掉真正的高位字符;exclude_categories:按 Unicode 通用类别(general category)排除字符。这里排除的是Cc(控制字符)和Cs(代理区/代理对中的代理码点)。
需要判断某个字符属于哪个 Unicode 类别时,可以用 Python 标准库unicodedata:
>>> from unicodedata import category >>> category('\n') 'Cc' >>> category('\t') 'Cc' >>> category(' ') 'Zs' >>> category('a') 'Ll'从当前仓库源码(core.py)可以确认characters的完整行为:
- 在不指定任何过滤规则时,任何字符都可能被生成;
categories与exclude_categories是"二选一"的互斥参数——它们描述的是同一件事的两种写法(只允许某些类别 vs. 排除某些类别),同时传入会抛出InvalidArgument;- 文档早期示例中出现的
blacklist_categories/whitelist_categories等参数,在当前版本中已作为弃用别名保留,仅用于向后兼容,推荐使用exclude_categories/categories; - 除类别与码点范围外,还支持
include_characters/exclude_characters显式收窄或补充具体字符,以及codec参数限定字符必须能被某编码方式编解码; characters的示例会向'0'的码点收缩(若'0'被排除则向允许的首个码点收缩)。
回到示例,把characters与text组合,就得到一个满足"非空、码点 1~1000、无控制字符、无代理区"的名称策略:
>>> names = text(characters(max_codepoint=1000, exclude_categories=('Cc', 'Cs')), min_size=1)这里characters(...)以策略的形式作为text的alphabet参数传入,每次画出一个字符都由它决定。
1.3 用map后处理:去掉首尾空格
当前names仍允许名称以空格开头或结尾,而这通常不是我们想要的。可以用find来"问"Hypothesis 是否存在满足条件的例子:
>>> find(names, lambda x: x[0] == ' ') ' 'find(specifier, condition)会从给定策略中返回满足条件的最小值,若找不到则抛出NoSuchExample(实现见 core.py,其内部本质是@given+ 捕获Found的搜索过程)。上面的结果证实了当前策略确实能生成以空格开头的名称。
要禁止首尾空格,用策略的map方法对生成结果做后处理——它把策略与任意函数组合,对每个生成值执行该函数:
>>> names = text(characters(max_codepoint=1000, exclude_categories=('Cc', 'Cs')), min_size=1).map( ... lambda x: x.strip())再次验证:
>>> find(names, lambda x: x[0] == ' ') Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/usr/lib/python3.5/site-packages/hypothesis/core.py", line 648, in find runner.run() File "/usr/lib/python3.5/site-packages/hypothesis/internal/conjecture/engine.py", line 168, in run self._run() ... IndexError: string index out of range糟糕!这里暴露了一个经典陷阱:min_size=1保证的是map之前的字符串非空。如果生成的全是空格,strip()之后就会变成空字符串,x[0]便越界了。
1.4 用filter补上不变式
解决办法是再叠加filter,把不满足条件的值丢弃:
>>> names = text(characters(max_codepoint=1000, exclude_categories=('Cc', 'Cs')), min_size=1).map( ... lambda s: s.strip()).filter(lambda s: len(s) > 0)重复检查:
>>> find(names, lambda x: x[0] == ' ') Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/usr/lib/python3.5/site-packages/hypothesis/core.py", line 670, in find raise NoSuchExample(get_pretty_function_description(condition)) hypothesis.errors.NoSuchExample: No examples found of condition lambda x: <unknown>NoSuchExample就是用来表示"不存在满足条件的例子"。在map/filter的实现层面,两者都会被记录为策略上的变换(transformation)链(见 strategies.py),filter还会记录调用点信息,用于健康检查与可观测性。
使用filter的原则:只用于过滤"不容易偶然出现"的条件。这里的过滤条件仅在初始抽取恰好是全空格字符串时失败,代价很低;反过来,如果我们试图过滤出"只有空格"的字符串,就会导致大量生成被丢弃,测试会变得非常慢且低效。
至此,我们得到了一个真正合格的names策略,并可以用一个专门的测试来验证其性质——虽然为策略写测试并不常见,但在调试策略本身时非常有用:
from unicodedata import category from hypothesis import given from hypothesis.strategies import characters, text names = ( text(characters(max_codepoint=1000, exclude_categories=("Cc", "Cs")), min_size=1) .map(lambda s: s.strip()) .filter(lambda s: len(s) > 0) ) @given(names) def test_names_match_our_requirements(name): assert len(name) > 0 assert name == name.strip() for c in name: assert 1 <= ord(c) <= 1000 assert category(c) not in ("Cc", "Cs")第二步:构造project_date策略——带时区与年份约束的日期
日期时间生成依赖pytz等时区库,在早期版本中位于hypothesis.extra子包;在当前仓库中它已经实现于 datetime.py,并从 hypothesis.strategies 直接导出,使用方式与之前完全一致:
>>> from hypothesis.strategies import datetimes >>> datetimes().example() datetime.datetime(1642, 1, 23, 2, 34, 28, 148985, tzinfo=<DstTzInfo 'Antarctica/Mawson' zzz0:00:00 STD>)不加任何约束时,Hypothesis 会覆盖整个可表示的时间历史,时区也各式各样。而我们项目的最佳实践是内部统一使用 UTC,展示层再做时区转换,所以先把时区限定为 UTC:
>>> datetimes(timezones=('UTC',)).example() datetime.datetime(6820, 2, 4, 19, 16, 27, 322062, tzinfo=<UTC>)再限制年份范围:
>>> datetimes(timezones=('UTC',), min_year=2000, max_year=2100).example() datetime.datetime(2084, 6, 9, 11, 48, 14, 213208, tzinfo=<UTC>)从源码看,datetimes(min_value=None, max_value=None, *, timezones=None, allow_imaginary=True)的边界语义如下:
- 如果
min_value/max_value都是 naive(或省略),则策略在两者之间抽取 naive 时间,再附加从timezones策略抽出的时区;timezones默认为none(),即默认生成 naive 时间; - 如果两个边界都是 aware(带时区),则它们被当作**时间上的时刻(instant)**处理,生成的每个值都落在两个时刻之间;
- 传一个 aware 和一个 naive 边界会报错;
timezones必须是能生成None或tzinfo对象的策略,也可以换成hypothesis.extra中dateutil/pytz提供的时区策略;allow_imaginary=False可过滤掉因夏令时、闰秒、时区与历法调整等原因而"从未真实存在"的虚构时间(imaginary datetimes)。默认允许虚构时间,因为畸形时间戳正是常见 bug 来源;同时该策略会刻意生成靠近夏令时切换、闰秒、千年之交、32 位 Unix 时间戳末端等边界的时间值,以覆盖这些高频出错点;- 收缩行为:示例向 2000 年 1 月 1 日午夜(当地时间)收缩。
同样可以用一个简短的测试来固定这些约束(代码虽短,验证价值有限,但能防止策略被误改):
from hypothesis import given from hypothesis.strategies import datetimes project_date = datetimes(timezones=("UTC",), min_year=2000, max_year=2100) @given(project_date) def test_dates_are_in_the_right_range(date): assert 2000 <= date.year <= 2100 assert date.tzinfo._tzname == "UTC"第三步:组装完整策略——builds与composite
3.1 先尝试builds:最直接但不够
现在三个字段的子策略都有了:names、project_date。如何把它们组装成一个生成Project的策略?最先该想到的是builds:
>>> from hypothesis.strategies import builds >>> projects = builds(Project, name=names, start=project_date, end=project_date) >>> projects.example() Project 'd!#ñcJν' from 2091-06-22T06:57:39.050162+00:00 to 2057-06-11T02:41:43.889510+00:00builds接收一组策略,把它们的生成结果作为参数传给目标可调用对象(函数、类构造器,任何 callable 都可以),从而得到一个新的策略。从源码(core.py)看,它还有更多能力:
- 若目标有类型注解,
builds会尝试为未显式提供的必填参数推断策略(内部走from_type);也可用...(Ellipsis)作为关键字参数,表示"这个可选参数请帮我推断"; - 对
attrs类,会基于属性及其校验器做最佳努力推断;数据类(dataclass)则由类型注解推断原生支持; - 收缩时通过收缩传入的参数值来收缩最终结果。
但直接使用builds有个问题——生成的日期关系不满足约束:
>>> find(projects, lambda x: x.start > x.end) Project '0' from 2000-01-01T00:00:00.000001+00:00 to 2000-01-01T00:00:00+00:00项目可能在结束之后才开始。一种修法是叠加filter:
>>> projects = builds(Project, name=names, start=project_date, end=project_date).filter( ... lambda p: p.start < p.end) >>> find(projects, lambda x: x.start > x.end) Traceback (most recent call last): ... hypothesis.errors.NoSuchExample: No examples found of condition lambda x: <unknown>这确实能工作,但已经开始触碰filter的适用边界——约有一半的初始生成会被过滤掉,浪费严重。更好的做法是从生成逻辑上消除无效组合。
3.2 使用composite:有依赖关系的组装
当参数之间存在依赖(比如两个日期要排序)时,builds就力不从心了。这时应该用它的"进阶亲戚"composite:
from hypothesis import assume from hypothesis.strategies import composite @composite def projects(draw): name = draw(names) date1 = draw(project_date) date2 = draw(project_date) assume(date1 != date2) start = min(date1, date2) end = max(date1, date2) return Project(name, start, end)composite的原理是:被装饰的函数会收到一个神奇的第一个参数draw,你可以用它从任意策略中抽取值,抽多少次都行,然后用这些值构造并返回目标数据。assume则用于"放弃当前这一次调用"——当进入无法继续、或重新开始更省事的状态时(比如这里两次抽到相同日期),它会标记该测试用例为无效而不是失败。从源码(control.py)看,assume(condition)在条件不满足时抛出UnsatisfiedAssumption,让引擎跳过该用例并尝试避免再次生成类似输入。
验证一下:
>>> projects().example() Project 'rĂ5ĠǓ#' from 2000-05-14T07:21:12.282521+00:00 to 2026-05-12T13:20:43.225796+00:00 >>> find(projects(), lambda x: x.start > x.end) Traceback (most recent call last): ... hypothesis.errors.NoSuchExample: No examples found of condition lambda x: <unknown>注意这里写的是projects()而不是projects——这是composite与普通策略的一个重要区别:composite返回的是一个函数,而不是策略本身。调用它才得到策略;定义函数时除第一个draw之外的其余参数,都会成为composite返回的那个函数的参数。
从源码(core.py)还能看到composite的若干细节:
- 它的示例通过收缩每次
draw的输出进行收缩; @composite不能混用"测试代码"与"生成代码";如果有这种需求,应改用st.data();- 装饰方法或类方法时,
draw参数必须放在self/cls之前(推荐写成独立函数再通过register_type_strategy与类关联,但方法形式也被支持)。
最后,用一条最终测试确认整个策略成立:
@given(projects()) def test_projects_end_after_they_started(project): assert project.start < project.end小结:策略组合的心智模型
回顾整个示例,可以提炼出几条可复用的经验:
- 从基础策略开始,用参数收敛范围:
text(min_size=...)、characters(min_codepoint=..., exclude_categories=...)、datetimes(timezones=..., min_year=..., max_year=...)——先让生成值进入"大致正确"的区域; map做转换,filter做收尾校验:map把原始抽取变换成期望形态(如strip()),filter丢弃仍不满足不变式的值;filter只用于低拒绝率的条件,高拒绝率的约束应改写到生成逻辑里;- 参数独立用
builds,参数有依赖用composite:builds适合"各抽各的、互不影响"的场景,还支持类型注解推断与attrs/ dataclass 特化;涉及参数间关系(排序、互斥、联动)时,composite+draw+assume是把约束写进生成逻辑的正道; - 用
find与"策略测试"验证策略本身:find(strategy, condition)能快速确认某类例子是否存在(不存在时抛NoSuchExample),必要时也可以为策略写@given测试,正如上文对names与日期所做的那样。
本文示例对应的完整教程来自 website/content/2016-05-11-generating-the-right-data.md;Hypothesis 的策略体系远不止本文提到的这些——完整的策略参考见 hypothesis/docs/reference/strategies.rst,数据生成相关的高级用法可进一步阅读 hypothesis/docs/usage.rst 与 hypothesis/docs/tutorial/custom-strategies.rst,遇到具体问题也可以在仓库的 hypothesis/docs/community.rst 所描述的社区渠道中寻求帮助。
- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
相关推荐
ScreenshotFramer深度解析:如何批量生成多语言应用商店截图
ScreenshotFramer深度解析:如何批量生成多语言应用商店截图 想要让你的应用在全球市场脱颖而出吗?ScreenshotFramer 是一款强大的批量
如何快速上手career-ops:从安装到生成第一份专业PDF简历的完整指南
如何快速上手career ops:从安装到生成第一份专业PDF简历的完整指南 career ops是一款基于Claude Code构建的AI驱动求职系统,提供1
人工智能AI 应用AI 技能终极炉石传说插件HsMod:55项功能完整指南与实战应用
终极炉石传说插件HsMod:55项功能完整指南与实战应用 HsMod是基于BepInEx框架开发的 炉石传说游戏增强插件 ,为玩家提供超过55项实用功能优化,涵
游戏开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考