当前位置:首页>python>python 013: Hypothesis 怎样帮你自动挖出边界 bug

python 013: Hypothesis 怎样帮你自动挖出边界 bug

  • 2026-09-25 15:42:34
python 013: Hypothesis 怎样帮你自动挖出边界 bug

别再只拿固定样例测代码:Hypothesis 怎样帮你自动挖出边界 bug

系列文章:Python 奇技淫巧 #013
很多测试之所以“总能通过”,并不是因为代码真的稳,而是因为你喂给它的输入太正常了:长度刚好、顺序理想、字符干净、数字不极端、状态变化也很规矩。这样的测试当然有价值,但它们经常只能证明“你手里这几个样例没问题”,证明不了“这段逻辑对一整类输入都靠谱”。Hypothesis 的厉害之处,就在于它把测试从“手工举例”升级成“自动探索输入空间”——你描述应该始终成立的性质,它负责自动生成样例、主动撞边界、缩小失败输入,最后把 bug 逼到一个最小可复现样例上。 这件事,对工具库、API 服务、数据清洗、AI 应用、状态机流程都很有杀伤力。


📌 为什么这篇值得写?

很多团队并不是不测,而是测法天然有盲区:

  • ❌ 样例全是“正常输入”,边界值、脏数据、极端组合没人覆盖
  • ❌ 测试函数写了不少,但本质上只是把 3 个案例复制成 10 个案例
  • ❌ 一旦要覆盖组合空间,测试数量指数膨胀,根本维护不过来
  • ❌ bug 真到了线上,回头一看才发现:缺的不是断言,而是输入探索
  • ❌ 某些逻辑只有在“多步操作”“特殊顺序”“很小但奇怪的输入”下才会出错

所以真正的问题不是:

“我还能再补几个测试样例?”

而是:

“我能不能描述一条应该始终成立的性质,然后让工具替我去撞那些我没想到的输入?”

这正是 Hypothesis 的价值。


🖼️ 先看图:Hypothesis 到底在替你做什么?

上面这张图是我自制的 SVG 信息图。SVG 本质上是用代码描述的矢量图,放大不糊、改字方便、适合版本管理,尤其适合技术文章里解释流程、结构和能力边界。像 Hypothesis 这种偏“测试方法升级”的工具,用 SVG 来讲会比截图更清楚。


🎯 Hypothesis 是什么?

Hypothesis 官方文档首页给它的定位非常直接:

Hypothesis is the property-based testing library for Python.

官方对它的进一步描述也很关键:你写的是“对某个输入范围内都应该成立的测试”,然后让 Hypothesis 自动去选择要检查哪些输入,包括你自己未必会主动想到的边界情况。

这意味着它和传统“手写固定样例”的测试思路不太一样:

Hypothesis 最值得记住的几个能力点,大概是:

  • @given(...)
    :告诉测试“输入从哪里来”
  • hypothesis.strategies
    :声明数据生成策略
  • shrinking
    :失败后自动把输入缩成更小、更容易理解的反例
  • @settings(...)
    :控制样例数量、数据库重放、运行阶段等
  • RuleBasedStateMachine
    :用状态机方式探索多步操作 bug
  • 能直接和 pytest、unittest 等常见测试方式协同

这里最重要的一句不是“它会随机造数据”,而是:

Hypothesis 不是随机测试玩具,而是一套“性质驱动 + 自动生成 + 最小反例收缩”的测试方法。


🆚 为什么固定样例总会漏掉边界 bug?

很多人最开始写测试,是这样想的:

def test_split_ok():
    assert split_into_batches([1, 2, 3, 4], 2) == [[1, 2], [3, 4]]


def test_split_tail():
    assert split_into_batches([1, 2, 3], 2) == [[1, 2], [3]]

这当然没错,但问题也很明显:

  • 你只检查了自己想到的两个输入
  • 你没有系统覆盖空列表、单元素、极大 size、极小 size 等边界
  • 如果 bug 藏在某个很奇怪但又很小的输入上,这类测试通常碰不到

而 Hypothesis 的思路是换个问法:

不去问“这两个样例结果对不对”,而去问“对于任意合法输入,这个函数有没有某些始终成立的性质?”

一旦你问对了问题,很多本来很难系统覆盖的边界,就能自动被扫出来。


1. 一个很典型的例子:批量切分函数明明看着没问题,为什么还是会漏数据?

来看一个非常常见的工具函数:把列表切成固定大小的小批次。

def split_into_batches(items: list[int], size: int) -> list[list[int]]:
    return [items[i : i + size] for i in range(0, len(items) - 1, size)]

这段代码乍一看很像真的,很多人甚至肉眼扫一遍都不一定立刻发现问题。但它其实有一个隐藏 bug:

  • 当列表长度比较特殊时,最后一个元素可能被悄悄漏掉

如果你手工写样例,很容易刚好写到几个“碰巧没暴露问题”的输入。更稳的做法是直接写性质:

这个函数至少该满足什么性质?

  1. 批次展平后,内容应该和原始输入完全一致
  2. 每个批次长度都不能超过 size
  3. 只要输入非空,批次里的每个子列表都不该是空的

对应的 Hypothesis 测试可以这样写:

from hypothesis import given, strategies as st


@given(
    items=st.lists(st.integers()),
    size=st.integers(min_value=1, max_value=20),
)
def test_split_into_batches_preserves_data(items, size):
    batches = split_into_batches(items, size)
    flattened = [x for batch in batches for x in batch]

    assert flattened == items
    assert all(len(batch) <= size for batch in batches)
    assert all(len(batch) > 0 for batch in batches)

这里真正妙的地方不在于语法,而在于你已经把测试目标从:

  • “验证 [1,2,3,4] 和 [1,2,3] 这两个例子”

升级成了:

  • “验证任意整型列表 + 任意合法批大小下,结果都必须满足这些性质”

如果这个实现真的有问题,Hypothesis 往往会很快给出一个失败输入,而且不满足于只给你一个巨大的脏例子,它还会继续 shrink。

你最后看到的,很可能是这种级别的反例:

Falsifying example: test_split_into_batches_preserves_data(
    items=[0],
    size=1,
)

这就是真正让人上头的地方:

它不只是替你找 bug,还会努力把 bug 压缩成一个最容易理解、最容易复现的最小失败样例。

这比“随机扔一大堆数据,然后报一个你根本看不懂的失败案例”强太多了。


2. @given 和 strategy,才是 Hypothesis 的核心入口

Hypothesis 的写法并不复杂。最常见的入口就是:

  • @given(...)
  • from hypothesis import strategies as st

其中 @given 表示“把生成出来的数据喂给测试函数”,而 st 则是用来描述数据空间的。

比如这些都是很常用的策略:

一个非常重要的思维变化是:

你不是在“列出输入”,而是在“描述输入空间”。

这两者看起来只差一点,测试能力却差很多。


3. strategy 不是只能用内置类型:map、filter、composite 才是工程价值放大的地方

官方文档专门有一节讲 strategy 的适配方式,核心就是:

  • .map()
    :把已有生成值映射成你想要的结构
  • .filter()
    :过滤掉不需要的输入
  • @st.composite
    :自定义更复杂、带依赖关系的数据生成器

这部分很关键,因为真实项目里的输入通常不是“一个整数”这么简单,而是:

  • API payload
  • 聊天消息列表
  • 嵌套配置
  • 数据清洗前后的结构体
  • 带状态约束的对象组合

来看一个更贴近 AI 服务项目的例子:生成聊天请求。

from hypothesis import given, strategies as st


@st.composite
def chat_requests(draw):
    message_count = draw(st.integers(min_value=1, max_value=6))
    messages = []

    for _ in range(message_count):
        role = draw(st.sampled_from(["system", "user", "assistant"]))
        content = draw(st.text(min_size=1, max_size=200))
        messages.append({"role": role, "content": content})

    temperature = draw(
        st.floats(
            min_value=0,
            max_value=2,
            allow_nan=False,
            allow_infinity=False,
        )
    )
    return {"messages": messages, "temperature": temperature}


@given(chat_requests())
def test_prompt_builder_never_drops_message(payload):
    prompt = "\n".join(f"{m['role']}: {m['content']}" for m in payload["messages"])

    for msg in payload["messages"]:
        assert msg["content"] in prompt

这类测试的意义,不在于它能替代全部业务测试,而在于它可以帮你快速验证:

  • 构造器会不会丢消息
  • 某些特殊字符会不会把拼接逻辑搞坏
  • 空格、空行、非 ASCII 字符会不会触发你没想到的问题

这里有一个经验判断:

  • 业务样例测试
     负责证明“用户关心的场景能工作”
  • Hypothesis 性质测试
     负责证明“输入空间里那些奇怪角落不会轻易把你炸掉”

这两类测试不是互相替代,而是互相补位。


4. Hypothesis 和 pytest 不是竞争关系,而是非常适合一起用

Hypothesis quickstart 里明确提到:一个 Hypothesis 测试仍然是普通 Python 函数,所以 pytest 或 unittest 都可以像平常一样收集和运行它。

这点非常重要,因为它意味着:

  • 你不用换测试框架
  • 你不用推翻已有 pytest 体系
  • fixture、参数化、marker 这些能力依然能继续用

一个很自然的组合方式是:

import pytest
from hypothesis import given, strategies as st


@pytest.mark.slow
@given(st.lists(st.integers(), min_size=1, max_size=50))
def test_sorted_result_is_ordered(values):
    result = sorted(values)
    assert result == sorted(result)

如果你已经把项目测试组织在 pytest 下,那么引入 Hypothesis 最舒服的姿势不是“新开一套测试体系”,而是:

在已有 pytest 工程里,挑那些最容易出边界 bug 的函数,增量补上 property-based tests。

这比全盘迁移轻得多,也实用得多。


🗺️ 再看一张图:Hypothesis 的测试主路径是什么?

这张图可以把 Hypothesis 的核心工作流压缩成一句话:

先定义性质,再描述输入空间,交给 Hypothesis 自动生成样例;一旦失败,就收缩成最小反例,再回到 pytest/CI 里稳定复现。

它最值钱的地方,不是“随机生成很多数据”,而是把“生成 → 发现 → 收缩 → 复现”这条链完整打通了。


5. @settings 不只是调参数,它在决定你怎么把 Hypothesis 用进工程里

官方文档对 @settings(...) 的说明很实在:你可以用它控制:

  • 生成多少个样例(max_examples)
  • 失败样例怎么重放
  • 哪些阶段启用(例如 replay、generate、shrink)
  • 输出详细程度
  • 是否启用数据库
  • 是否启用确定性运行(derandomize)

最常见的用法大概长这样:

from hypothesis import given, settings, strategies as st


@settings(max_examples=300, deadline=None)
@given(st.text(), st.integers(min_value=1, max_value=50))
def test_chunking_roundtrip(text, size):
    chunks = [text[i : i + size] for i in range(0, len(text), size)]
    assert "".join(chunks) == text

这里的几个工程理解很重要:

官方文档还提到 settings profile。这件事我很建议在工程里用起来,因为很适合区分:

  • 开发环境:样例少一点,反馈更快
  • CI 环境:样例多一点,探索更深
  • 某些专项测试:单独配置数据库或更详细日志

如果你已经有 pytest 的测试分层,再配合 Hypothesis settings profile,整套体验会非常顺。


6. 当 bug 不在“单次输入”,而在“多步操作顺序”时,就该看 stateful testing 了

Hypothesis 官方文档里还有一个很强、但很多人没认真学的能力:stateful testing。

官方对它的描述很准确:让 Hypothesis 不只选择“值”,还选择“动作”。也就是说,它不只是给你造输入,还会自动组合操作序列。

这对什么场景特别有用?

  • 缓存
  • 队列
  • 购物车
  • 权限状态切换
  • 会话生命周期
  • 数据结构实现(如栈、字典、索引)

核心入口通常是 RuleBasedStateMachine,再配合:

  • @rule(...)
    :定义可以执行的动作
  • @invariant(...)
    :定义每一步之后都必须成立的性质
  • Bundle
    :把前面步骤生成的数据传给后面规则

一个极简例子:

from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule


class CounterMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.values = []
        self.total = 0

    @rule(x=st.integers(min_value=0, max_value=100))
    def append_value(self, x):
        self.values.append(x)
        self.total += x

    @invariant()
    def total_matches_values(self):
        assert self.total == sum(self.values)

当然,真实项目往往会比这复杂得多,但它提供的方向非常清楚:

如果 bug 来自“操作顺序”和“状态转换”,而不是一个静态输入,那么单纯样例测试就不够,stateful testing 会更像对的问题。

这也是很多缓存、任务调度、业务状态流转 bug 的高发区。


7. Hypothesis 最容易被用错的 4 个地方

误区 1:把它当“随机造数据器”

如果你只是想随机生成一堆输入,却没有描述明确性质,那测试价值会迅速下降。

正确思路是先问:

  • 哪个性质必须始终成立?
  • 哪个不变量不能被打破?
  • 哪种 round-trip 必须可逆?

误区 2:filter() 和 assume() 用太狠

官方文档也提醒过,过度过滤会让输入生成效率变差。很多时候,更好的方式是:

  • 优先选更合适的内置 strategy
  • 用 map() / composite 把结构一次性建对
  • 只在必要时用 filter()

误区 3:性质写得太弱

比如只断言“函数返回了 list”,这几乎没有测试价值。

更有用的性质通常是:

  • 顺序是否保持
  • 长度关系是否成立
  • 编码/解码是否可逆
  • 排序结果是否满足单调性
  • 聚合结果是否满足守恒关系

误区 4:一上来就全仓库铺开

最稳的做法永远是:

  1. 先挑最容易藏边界 bug 的纯函数或工具函数
  2. 再挑解析、清洗、转换、分块、归一化这类逻辑
  3. 最后才考虑状态机、多步骤业务流

Hypothesis 很强,但真正高效的用法不是“到处乱上”,而是优先砸那些你知道自己最容易漏边界的地方。


8. 一套很实用的落地顺序

如果你现在已经在用 pytest,我会建议这样落地:

第一步:先挑 1 个最容易翻车的工具函数

优先考虑:

  • 分页/切片
  • 字符串清洗
  • 配置解析
  • JSON 转换
  • 时间区间处理
  • 去重、排序、归并

第二步:先写“性质”,再写 strategy

不要先想“我要生成什么奇怪数据”,而是先想:

  • 结果和输入应该保持什么关系?
  • 哪个约束永远不能破?
  • 哪个 round-trip 必须成立?

第三步:在 pytest 里增量加入

例如只在 tests/property/ 或 tests/unit/ 里先放几条 property-based tests,不要一开始就推倒重来。

第四步:给本地和 CI 分别配 settings

  • 本地:反馈快一点
  • CI:覆盖深一点

第五步:遇到状态问题,再引入 stateful testing

别一开始就上状态机,但一旦你的 bug 主要出在流程顺序,那就别继续死磕静态样例了。


9. 我对 Hypothesis 的一个明确判断

很多人把测试能力理解成“样例写得够不够多”,但我越来越觉得,真正决定测试上限的,是你有没有能力从“样例思维”切到“性质思维”。

而 Hypothesis 恰好就在做这件事:

  • 它逼你定义不变量
  • 它帮你探索输入空间
  • 它帮你自动缩小失败输入
  • 它让很多“只能靠经验撞到”的边界 bug,变成可以稳定复现的问题

所以我更愿意这样定义它:

Hypothesis 不是帮你多写测试,而是帮你把测试从“举例说明”推进到“系统证明”。

这就是它真正值钱的地方。


10. 一个可以直接落地的最小模板

from hypothesis import given, strategies as st


def reverse_twice(text: str) -> str:
    return text[::-1][::-1]


@given(st.text())
def test_reverse_twice_roundtrip(text):
    assert reverse_twice(text) == text

如果你已经在用 pytest,直接执行:

pip install hypothesis pytest
pytest

接着你就可以把最容易藏边界 bug 的那几个函数,逐步改写成 property-based tests。


结语

学 Hypothesis,别只学 @given 的语法。

真正要学会的是:

  • 怎么把“业务直觉”改写成“可验证性质”
  • 怎么把“几个样例”升级成“一个输入空间”
  • 怎么利用 shrinking 快速定位最小失败样例
  • 怎么把它平滑接进 pytest 和 CI
  • 怎么在必要时把单步测试升级成 stateful testing

当你这样理解 Hypothesis,它就不再只是“自动造数据”,而是“帮你主动挖边界 bug 的测试引擎”。

最新文章

随机文章