:为什么缩小后的失败示例总是“恰好“越过边界)
测试开发工具【免费下载链接】hypothesisThe property-based testing library for Python项目地址https://gitcode.com/gh_mirrors/hy/hypothesis点击查看免费下载属性化测试property-based testing在发现 bug 后会通过缩小shrinking机制把失败的输入约简成尽可能简单、便于人类理解的示例。但这个过程有一个反直觉的副作用当 bug 的触发条件可以被描述为某个分数超过某个阈值时缩小后的失败示例会倾向于停留在分数刚好略高于阈值的位置从而让 bug 看起来比真实情况轻微得多——这就是 Hypothesis 作者 David R. MacIver 在 《The Threshold Problem》 中系统阐述的问题。这篇文章将围绕该问题的成因、它如何引发 flaky 测试以及 Hypothesis 在源码层面给出的对策展开帮助你理解并规避属性化测试中这一类隐蔽的误导性失败示例。一、问题从何而来一个被低估的浮点 bug作者最初注意到这个问题源于 Ned Batchelder 的一次困惑他在测试浮点代码时写了一个断言——误差不得超过某个阈值例如 0.5。Hypothesis 很快给出了一个失败示例误差为 0.500001。看到这个数字任何人第一反应都是哦又是浮点精度问题差一点点而已。但进一步调查后发现完全不是这么回事误差实际上可以任意大只是 Hypothesis 每次都非常可靠地给出一个几乎刚够到失败线的示例。这不是偶然也不是 Hypothesis 的 bug。这正是 Hypothesis、QuickCheck 以及所有同类工具的设计方式测试用例约简test case reduction的目标是产生能演示该 bug 的最简单示例。如果一个 bug 可以表达为当某个分数超过某个阈值时发生而这个分数又倾向于随示例规模增大而增大那么属性化测试库给出的失败示例就会是那个分数刚好超过阈值一点点的示例——让问题看起来远没有实际严重。一个可复现的最小示例用 Hypothesis 复现这一现象非常简单。下面的测试对应仓库测试 hypothesis/tests/cover/test_deadline.py#L74-L85 中test_deadlines_participate_in_shrinking的场景import time from hypothesis import given, settings from hypothesis import strategies as st settings(deadline500, max_examples1000, databaseNone) given(st.integers(min_value0)) def test_slow_if_large(i): if i 1000: time.sleep(1) # 输入足够大时才会变慢当deadline500ms而i 1000时每个用例耗时超过 1 秒测试必然失败。但缩小结束后Hypothesis 给出的失败示例被约简为i 1000——恰好踩在慢与不慢的边界上而不是某个足以反映真实性能问题的更大数值。这个示例技术上完全正确地演示了 bug但它同时具有误导性真实场景中性能问题可能在任何大输入下都存在而缩小结果让你以为只要 i 恰好达到 1000 就会出问题。二、这不是缺陷而是缩小机制的设计使然文档中特别强调这甚至算不上 Hypothesis 的 bug——QuickCheck 或任何其他属性化测试工具都会做同样的事它字面意义上就是按设计工作的literally working as intended。从原理上讲缩小的本质是一种受控的贪心搜索Hypothesis 从失败的原始输入出发反复尝试删除、压缩或替换部分数据只要修改后的输入仍然触发同样的失败在当代实现中由interesting_origin标识同一类失败就保留这次更小的输入。该过程在 hypothesis/src/hypothesis/internal/conjecture/shrinker.py 中实现目标函数是最小化输入而不是最小化分数或最大化示例的说服力。因此对于分数超过阈值型 bug原始失败示例的分数可能远超阈值例如运行时间 5 秒缩小不断压小输入分数随之下降最终停在分数仍能触发失败的最小输入——也就是分数恰好刚超过阈值的位置。作者承认从Hypothesis 已证明 bug 存在且给出了容易理解的简单示例这个角度看这甚至不一定真的是个问题。但误导性示例会浪费用户的时间用户会像 Ned 一样把真正的 bug 误判为浮点精度、边界条件或偶发抖动从而错过对问题严重程度的正确评估。三、当阈值问题升级deadline 特性引发的 flaky 测试作者第二次遇到阈值问题场景更严重——它直接导致测试不稳定flaky。Hypothesis 在 Smarkets 资助的性能可读性performance legibility工作中引入了 deadline 特性单个用例的执行时长超过配置的 deadline 时该用例被视为失败并抛出DeadlineExceeded见 hypothesis/src/hypothesis/errors.py#L296-L302。DeadlineExceeded被当作普通错误处理照常参与缩小的全过程。问题在于这恰好是阈值问题的完美翻版分数 用例运行时间阈值 deadline分数超过阈值 → 测试失败大输入几乎必然更慢 → 缩小后总是停在刚好慢过 deadline的示例上。这本身还只是误导真正的麻烦在于Hypothesis 依赖可重复性来展示测试错误——一旦得到缩小后的示例它会重放该示例以便打印异常和输入。而运行时间本质上是不可重复的第一次跑 201ms 的测试第二次可能只跑 199ms。于是缩小得到一个运行时间刚好超过 deadline 的示例比如 201ms重放该示例时它只花了 199ms没有超过 deadlineHypothesis 据此判定测试flaky——上次抛DeadlineExceeded这次没有。这正是 Issue 892 中 Florian Bruhin 在测试 Qutebrowser 时踩到的坑。作者在文档中给出的解决方案是在缩小期间临时把 deadline 抬高到真实 deadline 与已观测到的最大运行时间的中间值从而确保缩小目标是比真实 deadline 更大的阈值这样重放时只要测试性能没有真正剧烈波动就能稳定地再次超过真实 deadline避免 flaky 误判。四、源码中的现代实现缩小阶段的放宽 deadline文档撰写于 2017 年而当前仓库中的实现已经演进为一套更精细的方案但思路一脉相承缩小阶段使用比正式执行更宽松的 deadline。在 hypothesis/src/hypothesis/core.py#L1041-L1052 的execute_once中可以看到核心逻辑if ( (current_deadline : self.settings.deadline) is not None # we disable the deadline check under concurrent threads, since # cpython may switch away from a thread for arbitrarily long. and not self.thread_overlap.get(threading.get_ident(), False) ): if not is_final: current_deadline (current_deadline // 4) * 5 if runtime current_deadline.total_seconds(): raise DeadlineExceeded( datetime.timedelta(secondsruntime), self.settings.deadline )关键在if not is_final: current_deadline (current_deadline // 4) * 5只要当前不是最终展示的那次执行即处于缩小等内部探索阶段deadline 就被放宽 25%原值的 125%。这与文档中缩小期间临时提高 deadline的方案目标一致——让缩小后的示例停在比真实 deadline 更高一点的阈值上从而在最终重放时留出时序波动余量避免恰好卡线的示例在重放时滑回通过侧。同时_settings.py的 deadline 属性文档也明确把它表述为软限制soft limitWe treat the deadline as a soft limit in some cases, where that would avoid flakiness due to timing variability.——正是对上述机制的设计说明。测试如何验证这一行为仓库测试 hypothesis/tests/cover/test_deadline.py 中有一段专门针对该机制的测试test_keeps_you_well_above_the_deadlineL88-L111断言缩小结果会稳定保持在 deadline 之上def test_keeps_you_well_above_the_deadline(): seen set() failed_once False settings(deadline100, backendhypothesis) given(st.integers(0, 2000)) def slow(i): nonlocal failed_once # Make sure our initial failure isnt something that immediately goes flaky. if not failed_once: if i * 0.9 100: return else: failed_once True t i / 1000 if i in seen: time.sleep(0.9 * t) # 重放时故意只慢 90% else: seen.add(i) time.sleep(t) with pytest.raises(DeadlineExceeded): slow()注意重放路径time.sleep(0.9 * t)如果缩小停在恰好等于真实 deadline 的输入上重放时 90% 的耗时会让它变快而不再失败而放宽后的阈值125%迫使缩小结果停在更高的输入上重放时即使只慢 90% 仍会稳定超过真实 deadline——测试以此保证DeadlineExceeded始终被正确抛出而不是被误判为 flaky。配套的test_gives_a_deadline_specific_flaky_error_messageL114-L127则验证了文档中提到的改进错误消息当测试确实在首次运行超时、重放却变快时Hypothesis 会报告Unreliable test timing这样的专属提示并建议用户设置deadlineNone关闭截止时间。五、更普遍的解法设想把分数交给 Hypothesisdeadline 的解决方案虽然有效但作者明确说明它非常特化于 deadline 场景——deadline 因为时序不可靠与其他阈值问题有不同约束。作者更期待一个通用的解决方案并在文档中提出了一个前瞻性设想给 Hypothesis 引入评分scoring概念。设想的使用场景包括测试游戏类程序时把打到的关卡数或游戏分数作为 score 提供给 Hypothesis任何可以量化的进度指标都可以作为 score。其运作方式是让用户把 score 暴露给 Hypothesis或由 Hypothesis 自动推断缩小完成后Hypothesis 检查缩小示例的 score 与初始失败示例的 score 是否差异悬殊如果差异悬殊Hypothesis 以score 保持接近原始示例作为附加约束重新运行缩小过程把重新缩小的示例与原缩小示例并排展示给用户。作者设想这可以融入多失败报告multiple failures reporting机制——即当前仓库中report_multiple_bugs设置见 hypothesis/src/hypothesis/_settings.py#L55与interesting_origin失败分类所支撑的能力——让用户同时看到最小示例与更能反映真实严重程度的示例。这样既保留了缩小的可读性又避免了阈值问题造成的严重性误判。作者也坦承这需要更多思考才能落地且不需要急于求成一个完全通用的方案。但它点明了属性化测试工具一个重要的演进方向——缩小只是让问题对用户可读的起点不是终点。六、对使用者的启示回顾全文阈值问题对 Hypothesis 使用者有几点直接的实践启示看到刚好卡线的失败示例时保持警惕。缩小示例0.500001、i 1000、201ms这类擦边数值很可能只是缩小机制的产物不代表 bug 的真实边界。应主动放大输入规模确认问题是否在更宽范围内存在。deadline 相关的 flaky 不一定是测试真的不稳定。Hypothesis 已通过缩小阶段放宽 deadline 至 125%core.py#L1047-L1048和专属错误消息来缓解若你的测试确实存在正常时序波动可在settings中显式设置deadlineNone当前仓库默认 deadline 为 200msCI profile 下默认为None见 _settings.py#L630 与 L1227-L1233。阈值问题不是 Hypothesis 的缺陷。QuickCheck 等同类工具行为一致它是最小化示例这一设计目标的必然副产品。理解这一点有助于把精力放在如何解读失败示例而非如何绕过缩小上。相关阅读上一篇同系列文章《Multi-Bug Discovery》——阐述了 bug slippage 问题即缩小过程中从一个 bug 滑向另一个 bug 的现象与本篇的阈值问题同属缩小机制的可读性边界话题。deadline 设置与验证逻辑——deadline 的取值校验int/float 毫秒、timedelta、None禁用与默认值定义。DeadlineExceeded 异常定义——运行时与 deadline 的具体承载结构。deadline 相关测试全集——覆盖超时抛出、缩小参与、flaky 重放、专属错误消息等全部行为。赞分享测试开发工具【免费下载链接】hypothesisThe property-based testing library for Python项目地址https://gitcode.com/gh_mirrors/hy/hypothesis点击查看免费下载相关推荐为什么你的Compose Multiplatform项目升级后总是构建失败5个关键解决方案大揭秘 ️为什么你的Compose Multiplatform项目升级后总是构建失败5个关键解决方案大揭秘 ️ Compose Multiplatform作为Jet前端跨平台UI组件移动开发桌面应用为什么你的BrushNet配置总是失败5分钟搞定模型路径问题为什么你的BrushNet配置总是失败5分钟搞定模型路径问题 你知道吗BrushNet作为ComfyUI中强大的图像修复插件却因为一个小小的模型路径问题让人工智能计算机视觉AI 应用CSS3 Buttons自定义教程从零开始设计专属按钮样式的完整指南CSS3 Buttons自定义教程从零开始设计专属按钮样式的完整指南 想要为你的网站或应用添加精美、现代化的按钮样式吗CSS3 Buttons项目为你提供了上一篇探索Adobe Experience Manager安全漏洞的利器AEM Hacking Toolset下一篇探索个性世界IBM Personality Insights 示例应用创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考