资讯动态

用 Hypothesis 生成正确的数据:从字段策略到领域对象的完整实战

发布时间:2026/9/25 11:37:31 来源:尧图企业网站定制
测试开发工具【免费下载链接】hypothesisThe property-based testing library for Python项目地址https://gitcode.com/gh_mirrors/hy/hypothesis点击查看免费下载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 fProject {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_size1 text(min_size1).example() w\nC text(min_size1).example() ሚಃJ»从源码看text的签名是text(alphabetcharacters(codecutf-8), *, min_size0, max_sizeNone)见 core.py。有两个细节值得注意alphabet既可以是一个字符集合collection也可以是生成单个字符的策略默认的alphabet使用characters(codecutf-8)即可以覆盖整个 Unicode 范围但会排除代理区字符surrogate因为它们无法用 UTF-8 编码。1.2 用characters限制字符范围默认text()可能生成高位 Unicode 字符。虽然一个健壮的系统理应正确处理完整 Unicode 范围但作为示例我们先限制一下生成范围。此时需要用到characters策略——它提供了一种灵活的方式来描述单字符文本的生成规则 characters(min_codepoint1, max_codepoint1000, exclude_categories(Cc, Cs)).example() ² characters(min_codepoint1, max_codepoint1000, exclude_categories(Cc, Cs)).example() E characters(min_codepoint1, max_codepoint1000, 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_codepoint1000, exclude_categories(Cc, Cs)), min_size1)这里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_codepoint1000, exclude_categories(Cc, Cs)), min_size1).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_size1保证的是map之前的字符串非空。如果生成的全是空格strip()之后就会变成空字符串x[0]便越界了。1.4 用filter补上不变式解决办法是再叠加filter把不满足条件的值丢弃 names text(characters(max_codepoint1000, exclude_categories(Cc, Cs)), min_size1).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: unknownNoSuchExample就是用来表示不存在满足条件的例子。在map/filter的实现层面两者都会被记录为策略上的变换transformation链见 strategies.pyfilter还会记录调用点信息用于健康检查与可观测性。使用filter的原则只用于过滤不容易偶然出现的条件。这里的过滤条件仅在初始抽取恰好是全空格字符串时失败代价很低反过来如果我们试图过滤出只有空格的字符串就会导致大量生成被丢弃测试会变得非常慢且低效。至此我们得到了一个真正合格的names策略并可以用一个专门的测试来验证其性质——虽然为策略写测试并不常见但在调试策略本身时非常有用from unicodedata import category from hypothesis import given from hypothesis.strategies import characters, text names ( text(characters(max_codepoint1000, exclude_categories(Cc, Cs)), min_size1) .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, tzinfoDstTzInfo Antarctica/Mawson zzz0:00:00 STD)不加任何约束时Hypothesis 会覆盖整个可表示的时间历史时区也各式各样。而我们项目的最佳实践是内部统一使用 UTC展示层再做时区转换所以先把时区限定为 UTC datetimes(timezones(UTC,)).example() datetime.datetime(6820, 2, 4, 19, 16, 27, 322062, tzinfoUTC)再限制年份范围 datetimes(timezones(UTC,), min_year2000, max_year2100).example() datetime.datetime(2084, 6, 9, 11, 48, 14, 213208, tzinfoUTC)从源码看datetimes(min_valueNone, max_valueNone, *, timezonesNone, allow_imaginaryTrue)的边界语义如下如果min_value/max_value都是 naive或省略则策略在两者之间抽取 naive 时间再附加从timezones策略抽出的时区timezones默认为none()即默认生成 naive 时间如果两个边界都是 aware带时区则它们被当作**时间上的时刻instant**处理生成的每个值都落在两个时刻之间传一个 aware 和一个 naive 边界会报错timezones必须是能生成None或tzinfo对象的策略也可以换成hypothesis.extra中dateutil/pytz提供的时区策略allow_imaginaryFalse可过滤掉因夏令时、闰秒、时区与历法调整等原因而从未真实存在的虚构时间imaginary datetimes。默认允许虚构时间因为畸形时间戳正是常见 bug 来源同时该策略会刻意生成靠近夏令时切换、闰秒、千年之交、32 位 Unix 时间戳末端等边界的时间值以覆盖这些高频出错点收缩行为示例向 2000 年 1 月 1 日午夜当地时间收缩。同样可以用一个简短的测试来固定这些约束代码虽短验证价值有限但能防止策略被误改from hypothesis import given from hypothesis.strategies import datetimes project_date datetimes(timezones(UTC,), min_year2000, max_year2100) given(project_date) def test_dates_are_in_the_right_range(date): assert 2000 date.year 2100 assert date.tzinfo._tzname UTC第三步组装完整策略——builds与composite3.1 先尝试builds最直接但不够现在三个字段的子策略都有了names、project_date。如何把它们组装成一个生成Project的策略最先该想到的是builds from hypothesis.strategies import builds projects builds(Project, namenames, startproject_date, endproject_date) projects.example() Project d!#ñcJν from 2091-06-22T06:57:39.05016200:00 to 2057-06-11T02:41:43.88951000: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.00000100:00 to 2000-01-01T00:00:0000:00项目可能在结束之后才开始。一种修法是叠加filter projects builds(Project, namenames, startproject_date, endproject_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就力不从心了。这时应该用它的进阶亲戚compositefrom 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.28252100:00 to 2026-05-12T13:20:43.22579600: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参数有依赖用compositebuilds适合各抽各的、互不影响的场景还支持类型注解推断与attrs/ dataclass 特化涉及参数间关系排序、互斥、联动时compositedrawassume是把约束写进生成逻辑的正道用find与策略测试验证策略本身find(strategy, condition)能快速确认某类例子是否存在不存在时抛NoSuchExample必要时也可以为策略写given测试正如上文对names与日期所做的那样。本文示例对应的完整教程来自 website/content/2016-05-11-generating-the-right-data.mdHypothesis 的策略体系远不止本文提到的这些——完整的策略参考见 hypothesis/docs/reference/strategies.rst数据生成相关的高级用法可进一步阅读 hypothesis/docs/usage.rst 与 hypothesis/docs/tutorial/custom-strategies.rst遇到具体问题也可以在仓库的 hypothesis/docs/community.rst 所描述的社区渠道中寻求帮助。赞分享测试开发工具【免费下载链接】hypothesisThe property-based testing library for Python项目地址https://gitcode.com/gh_mirrors/hy/hypothesis点击查看免费下载相关推荐ScreenshotFramer深度解析如何批量生成多语言应用商店截图ScreenshotFramer深度解析如何批量生成多语言应用商店截图 想要让你的应用在全球市场脱颖而出吗ScreenshotFramer 是一款强大的批量如何快速上手career-ops从安装到生成第一份专业PDF简历的完整指南如何快速上手career ops从安装到生成第一份专业PDF简历的完整指南 career ops是一款基于Claude Code构建的AI驱动求职系统提供1人工智能AI 应用AI 技能终极炉石传说插件HsMod55项功能完整指南与实战应用终极炉石传说插件HsMod55项功能完整指南与实战应用 HsMod是基于BepInEx框架开发的 炉石传说游戏增强插件 为玩家提供超过55项实用功能优化涵游戏开发上一篇3个秘诀彻底掌控你的微信聊天记忆从数据备份到情感珍藏下一篇抖音下载神器douyin-downloader完整解决方案与高效使用指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑