← 返回题目列表

什么是基于属性的测试?Hypothesis 怎么用?

中等 第 22 / 27 题 更新于 2026/08/03
Hypothesis属性测试测试设计边界条件

简化版

基于属性的测试(property-based testing)不列举「具体的输入和期望输出」,而是描述「输入应该满足什么条件」和「结果应该满足什么性质」,然后让框架自动生成大量随机输入去验证。Python 的实现是 Hypothesis@given(st.integers()) 会自动生成上百个整数(包括 0、1、-1、2**63、极大极小值这些人类容易忘记的边界)跑同一个测试函数。它相比手写用例的两个核心优势① 覆盖你想不到的边界——人写测试时会不自觉地只写「典型的、自己想得到的」输入,而框架会系统性地探索边界值、特殊字符(\x00、emoji、代理对)、极端长度;② 自动收缩(shrinking)——找到失败用例后,它会自动把反例简化到最小(比如从 [-3847, 0, 291, 5] 收缩成 [0]),让你一眼看出问题所在,这是 property-based testing 最有价值的特性。关键在于「找到合适的属性」,常见的四类:不变量(排序后长度不变、元素集合不变)、往返decode(encode(x)) == x最容易想到也最有用)、等价性(新实现和旧实现结果一致、优化前后一致)、元操作关系sorted(a+b) == merge(sorted(a), sorted(b)))。但它不是万能的它不能替代示例测试assert add(2,3) == 5 这种具体例子对理解和文档价值更高)、属性写错了会给出虚假的安全感、而且运行更慢(每次跑几百个用例)。实践上最适合它的场景是:解析器/序列化、数据结构实现、算法、金额和日期计算、任何有明确数学性质的纯函数。核心记忆:描述性质而不是列举例子自动生成 + 自动收缩到最小反例四类常见属性:不变量/往返/等价/元操作补充而不是替代示例测试

详细版

示例测试 vs 属性测试

示例测试(parametrize)属性测试(Hypothesis)
你写什么具体输入 + 期望输出输入范围 + 应满足的性质
覆盖只覆盖你想到的系统性探索边界
失败信息就是那组数据自动收缩到最小反例
可读性好(就是例子)需要理解属性
速度慢(几百次)
适合文档、典型场景、回归算法、解析、纯函数
from hypothesis import given, strategies as st, assume, settings, example
import hypothesis.strategies as st

# ① ★最简单的例子★
@given(st.integers())
def test_abs_non_negative(n):
    assert abs(n) >= 0                     # ★★自动生成上百个整数★★
# ★Hypothesis 会试:0, 1, -1, 2**31, -2**63, 随机值...★

# ② ★★四类常见属性★★
# ★(1)不变量:操作后某个性质保持★
@given(st.lists(st.integers()))
def test_sort_invariants(xs):
    result = my_sort(xs)
assert len(result) == len(xs)★                    # 长度不变
assert sorted(result) == sorted(xs)★              # ★元素集合不变★
assert all(a <= b for a, b in zip(result, result[1:]))★  # 有序

# ★(2)往返(round-trip)——★最有用★★
@given(st.text())
def test_encode_decode(s):
assert decode(encode(s)) == s★                    # ★★编解码往返★★

@given(st.dictionaries(st.text(), st.integers()))
def test_json_roundtrip(d):
    assert json.loads(json.dumps(d)) == d

# ★(3)等价性:两个实现结果一致★
@given(st.lists(st.integers()))
def test_optimized_matches_naive(xs):
assert fast_algorithm(xs) == naive_algorithm(xs)★  # ★重构/优化的守护★

# ★(4)元操作关系★
@given(st.lists(st.integers()), st.lists(st.integers()))
def test_merge_property(a, b):
assert merge(sorted(a), sorted(b)) == sorted(a + b)★

# ③ ★★常用策略(strategies)★★
st.integers(min_value=0, max_value=100)
st.floats(★allow_nan=False, allow_infinity=False★)     # ★★默认会生成 nan!★★
st.text(★alphabet=st.characters(min_codepoint=32, max_codepoint=126)★)
st.lists(st.integers(), min_size=1, max_size=10, ★unique=True★)
st.dictionaries(st.text(), st.integers())
st.sampled_from(["a", "b", "c"])                     # ★从固定集合选★
st.one_of(st.none(), st.integers())                  # ★或 st.none() | st.integers()★
st.datetimes(min_value=datetime(2000,1,1))
st.decimals(★allow_nan=False, places=2★)              # ★金额★
st.uuids() / st.emails() / st.ip_addresses()
st.builds(User, name=st.text(), age=st.integers(0, 120))   # ★★构造对象★★
st.from_type(MyDataclass)                            # ★从类型推导★

# ④ ★组合与派生★
@st.composite
def user_with_orders(draw):                          # ★自定义策略★
    user = draw(st.builds(User, id=st.integers(1, 1000)))
    n = draw(st.integers(0, 5))
    orders = [draw(st.builds(Order, user_id=st.just(user.id)))
              for _ in range(n)]
    return user, orders

st.integers().★map(lambda x: x * 2)★                  # 变换
st.integers().★filter(lambda x: x % 2 == 0)★          # ★★过滤(慎用,慢)★★
st.integers().★flatmap(lambda n: st.lists(st.integers(), min_size=n))★

# ⑤ ★assume:排除不关心的输入★
@given(st.integers(), st.integers())
def test_divide(a, b):
    ★assume(b != 0)★                                  # ★★跳过而不是失败★★
    assert divide(a, b) * b == pytest.approx(a)
# ★✗ assume 过滤掉太多会报 FailedHealthCheck → 改用带约束的策略★
# ✓ st.integers().filter(lambda b: b != 0)  或直接 min_value=1

# ⑥ ★★settings:控制运行★★
@settings(max_examples=1000, deadline=None)          # ★默认 100 个例子★
@given(st.text())
def test_slow(s): ...

# ★全局配置★
settings.register_profile("ci", max_examples=1000, deadline=None)
settings.register_profile("dev", max_examples=20)
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))

# ⑦ ★★@example:把找到的反例固定下来★★
@★example(s="")★                                      # ★★永远会测这个★★
@example(s="\x00")
@given(st.text())
def test_parse(s): ...
# ★★发现 bug 后加一个 @example 作为回归测试★★

⚠️ 三个必须记住的点:① 自动收缩(shrinking)是 Hypothesis 最有价值的特性。当它找到一个失败的输入时,不会直接把那个「乱七八糟的随机值」丢给你,而是自动尝试简化:把大数变小、把长列表变短、把复杂字符串变成简单字符——直到找到仍然会失败的最小输入。所以你看到的报错通常是 Falsifying example: xs=[0]s='' 这种极简的反例,问题的本质一眼可见。这和「随机测试」有本质区别——纯随机生成给你一个 500 元素的列表让你自己排查,而 Hypothesis 给你最小反例。② 写好属性比写测试更难,而且属性写错了会给虚假的安全感。最常见的错误是用被测逻辑本身来表达属性assert my_sort(xs) == sorted(xs) 只是在测试标准库)、或者属性太弱(只断言「不抛异常」,那几乎什么都没验证)。好的属性应该是独立于实现的、来自问题域本身的规律。如果实在想不出属性,「往返」是最容易想到也最有用的一类(编码解码、序列化反序列化、加密解密、存取)。③ st.floats() 默认会生成 naninf——而 nan != nan,所以任何涉及浮点相等的断言都会失败。这不是 Hypothesis 的 bug,而是它在正确地提醒你「你的代码考虑过 nan 吗」。如果业务上确实不会出现 nan,就显式写 st.floats(allow_nan=False, allow_infinity=False);同理 st.text() 会生成空串、\x00、emoji、Unicode 代理对——这些恰恰是最容易出 bug 的输入。

完整版教学

一、为什么需要属性测试

★ ★人类写测试用例的系统性盲区★:
  ┌────────────────────────────────────────────────────┐
  │ ★写测试的人和写代码的是同一个人★                     │
  │ → ★他想不到的边界,测试里也不会有★                   │
  │ → ★「我觉得这样用」的假设会同时进入代码和测试★        │
  └────────────────────────────────────────────────────┘
  ★ 典型被遗漏的输入:
    - ★空值:""、[]、{}、0、None★
    - ★极值:sys.maxsize、-2**63、1e308★
    - ★特殊字符:\x00、\n、emoji、★Unicode 代理对★★
    - ★浮点:nan、inf、-0.0、精度累积★
    - ★超长:10 万字符的字符串★
    - ★重复元素、已排序、逆序、全相同★

★ ★一个真实的例子★:
  def normalize_name(s: str) -> str:
      return s.strip().title()
  # ★手写测试★
  assert normalize_name(" alice ") == "Alice"      # ✓ 通过
  # ★Hypothesis★
  @given(st.text())
  def test_idempotent(s):
      ★assert normalize_name(normalize_name(s)) == normalize_name(s)★
  # ★★Falsifying example: s = "Dž"★★  ← ★title() 对某些 Unicode 字符不幂等★
  ★ ★这种输入人类几乎不可能想到★

★ ★属性测试 vs 模糊测试(fuzzing)★:
  ┌──────────────┬────────────────────────────────────┐
  │ ★Fuzzing★     │ ★随机输入,只看崩不崩★              │
  │              │ 目标:内存安全、未处理的异常         │
  │ ★属性测试★    │ ★随机输入 + ★验证语义正确性★★       │
  │              │ 目标:逻辑正确                      │
  │              │ ★+ 自动收缩到最小反例★              │
  └──────────────┴────────────────────────────────────┘

★ ★Hypothesis 的智能之处★:
  ① ★不是纯随机★——★优先尝试已知容易出问题的值★
     (0、1、-1、空、最大最小、特殊字符)
  ② ★★收缩(shrinking)★★——失败后自动简化反例
  ③ ★★示例数据库(.hypothesis/examples)★★
     → ★上次失败的输入会被记住,下次优先重试★
     → ★所以修复后要再跑一次确认★
     → ★CI 上建议缓存这个目录★
  ④ ★覆盖率引导★(较新版本会用覆盖率信息指导生成)

★ ★★收缩的效果(关键卖点)★★:
  # 假设 bug 是"列表含 0 时出错"
  ★第一次找到的失败输入:★
    xs = [-8347, 2910, 0, -55, 8823, 1, 77, -2]
  ★收缩后报告的:★
    ★Falsifying example: xs=[0]★
  → ★★一眼看出:列表含 0 就出错★★
  ★ ★没有收缩的话,你要自己从 8 个数里找规律★

属性测试解决的是「人类写测试用例的系统性盲区」——写测试的人和写代码的是同一个人,他想不到的边界,测试里也不会有。那个 normalize_name 的例子很典型:手写测试全过,而 Hypothesis 找到 "Dž" 这个字符让 title() 不幂等——这种输入人类几乎不可能想到它和模糊测试的区别是「验证语义正确性」而不只是「看崩不崩」。Hypothesis 的智能体现在四点:优先尝试已知容易出问题的值(不是纯随机)、自动收缩示例数据库记住上次的失败输入CI 上建议缓存 .hypothesis 目录)、以及覆盖率引导。收缩是关键卖点——从 8 个随机数收缩成 [0],问题本质一眼可见。

二、找到好的属性

★ ★★四类经典属性(按易用性排序)★★:

★ ①「往返」(Round-trip)——★最容易想到、最有用★
  ★decode(encode(x)) == x★
  ★parse(serialize(x)) == x★
  ★from_json(to_json(x)) == x★
  ★decompress(compress(x)) == x★
  ★db.get(db.save(x).id) == x★
  ★ ✓ 适用:★序列化、编解码、压缩、加密、存取★
  ★ ★只要有"一对互逆操作"就能用★

★ ②「不变量」(Invariant)——★操作前后某性质不变★
  ★len(sort(xs)) == len(xs)★
  ★sum(rebalance(portfolio)) == sum(portfolio)★     # ★总额守恒★
  ★set(shuffle(xs)) == set(xs)★
  ★所有余额之和在转账前后不变★
  ★ ✓ 适用:★排序、洗牌、重分配、状态机转换★

★ ③「等价性」(Oracle)——★和一个"已知正确"的实现对比★
  ★fast_impl(x) == naive_impl(x)★                   # ★优化的守护★
  ★new_version(x) == old_version(x)★                # ★重构的守护★
  ★my_json.dumps(x) == json.dumps(x)★
  ★ ✓ ★重写/优化时最有价值★
  ★ ✗ ★如果没有参考实现就用不了★

★ ④「元操作关系」(Metamorphic)——★输入变化 → 输出的可预测变化★
  ★sorted(a + b) == merge(sorted(a), sorted(b))★
  ★f(x) + f(y) == f(x + y)★                         # 可加性
  ★search(q) ⊇ search(q + " extra")★                # ★加条件结果变少★
  ★rotate(rotate(img, 90), 270) == img★
  ★ ✓ ★没有参考实现时的救星★
  ★ ★思路:"如果我这样改输入,输出应该怎么变?"★

★ ★其他实用的属性模板★:
  ★幂等性★:f(f(x)) == f(x)                    # normalize、clean、dedupe
  ★交换律★:f(a, b) == f(b, a)                 # max、并集
  ★结合律★:f(f(a,b),c) == f(a,f(b,c))
  ★单位元★:f(x, identity) == x
  ★不抛异常★:★最弱但有价值★(尤其是解析器)
    @given(st.binary())
    def test_parser_no_crash(data):
        try: parse(data)
        except ★ParseError★: pass                # ★只允许这一种异常★
  ★保序性★:a <= b → f(a) <= f(b)
  ★边界★:f(x) 总是在 [min, max] 内

★ ★★写不出属性怎么办★★:
  ① ★先问:"这个函数的输出,有什么是永远成立的?"★
  ② ★问:"有没有一个笨但明显正确的实现可以对比?"★
  ③ ★问:"输入变一点,输出应该怎么变?"★
  ④ ★退而求其次:不抛异常 + 输出类型正确 + 输出在合理范围★
  ⑤ ★还是想不出 → 这个函数可能不适合属性测试★

★ ★★属性写错的两种典型★★:
  ✗ ★用被测逻辑表达属性★
    @given(st.lists(st.integers()))
    def test_sort(xs):
        ★assert my_sort(xs) == sorted(xs)★         # ★这在测标准库★
    ★ 更好:断言"有序 + 元素集合相同"两个独立性质
  ✗ ★属性太弱★
    def test_x(s): ★assert process(s) is not None★  # ★几乎没验证什么★
  ✗ ★属性太强(把实现细节写进去)★
    assert result == [x*2 for x in xs]            # ★等于重写了实现★

四类经典属性按易用性排序「往返」最容易想到也最有用(只要有一对互逆操作就能用——编解码、序列化、压缩、存取);「不变量」(排序后长度和元素集合不变、转账后总额守恒);「等价性」(和已知正确的实现对比,重写和优化时最有价值);「元操作关系」没有参考实现时的救星,思路是「如果我这样改输入,输出应该怎么变」)。写不出属性时的五步递进:先问「什么是永远成立的」→「有没有笨但正确的实现可对比」→「输入变一点输出怎么变」→ 退而求其次断言「不抛异常 + 类型正确 + 在合理范围」→ 还想不出就说明这个函数可能不适合属性测试属性写错有两种典型用被测逻辑表达属性assert my_sort(xs) == sorted(xs) 是在测标准库)和属性太弱(只断言 is not None)。

三、策略(Strategies)

★ ★策略就是"如何生成数据"的描述★:
  ★基础类型★
    st.integers(min_value=, max_value=)
    st.floats(★allow_nan=False, allow_infinity=False★, width=32)
    st.booleans() / st.none()
    st.text(alphabet=, min_size=, max_size=)
    st.binary(min_size=, max_size=)
    st.characters(★min_codepoint=, max_codepoint=,
                  blacklist_categories=["Cs"]★)     # ★★排除代理对★★

  ★容器★
    st.lists(elem, min_size=, max_size=, ★unique=True★, unique_by=)
    st.sets(elem) / st.frozensets(elem)
    st.dictionaries(keys, values, min_size=)
    st.tuples(st.integers(), st.text())            # ★固定结构★
    st.iterables(elem)

  ★组合★
    ★st.one_of(a, b)★  或  ★a | b★
    ★st.sampled_from([...])★                       # 从枚举选
    ★st.just(value)★                               # 固定值
    ★st.nothing()★                                 # 不生成(用于条件分支)

  ★领域类型★
    st.datetimes(min_value=, max_value=, timezones=)
    st.dates() / st.times() / st.timedeltas()
    st.decimals(min_value=, max_value=, ★places=2★)  # ★金额★
    st.uuids() / st.emails() / st.ip_addresses()
    st.from_regex(r"\d{3}-\d{4}", ★fullmatch=True★)

★ ★★构造对象:builds / from_type★★:
  @dataclass
  class User:
      name: str
      age: int
      email: str

  # ★方式一:builds(显式)★
  user_st = ★st.builds(User,
      name=st.text(min_size=1, max_size=50),
      age=st.integers(0, 120),
      email=st.emails())★

  # ★方式二:from_type(从类型注解推导)★
  ★user_st = st.from_type(User)★
  # ★注册自定义类型的策略★
  ★st.register_type_strategy(Email, st.emails().map(Email))★

★ ★★@st.composite:有依赖关系的数据★★:
  @st.composite
  def order_with_items(draw):
      n = draw(st.integers(1, 5))
      items = draw(st.lists(item_strategy, min_size=n, max_size=n))
      ★total = sum(i.price * i.qty for i in items)★   # ★★依赖前面的值★★
      discount = draw(st.decimals(0, total, places=2))
      return Order(items=items, total=total, discount=discount)

  @given(★order_with_items()★)
  def test_order(order): ...

★ ★map / filter / flatmap★:
  ★st.integers().map(str)★                          # 变换
  ★st.text().map(str.strip).filter(lambda s: s)★    # ★组合★
  ★st.lists(st.integers()).flatmap(
      lambda xs: st.tuples(st.just(xs), st.sampled_from(xs)))★  # ★依赖生成★
  ★ ★filter 慎用★:过滤率太低会 ★FailedHealthCheck★
    ✗ st.integers().filter(lambda x: x > 1000000)   # ★几乎全被过滤★
    ✓ st.integers(min_value=1000001)                # ★★用约束而不是过滤★★

★ ★递归数据(JSON 之类)★:
  json_st = st.recursive(
      st.none() | st.booleans() | st.integers() | st.text(),
      lambda children: st.lists(children) | st.dictionaries(st.text(), children),
      ★max_leaves=10★,
  )
  @given(json_st)
  def test_json_roundtrip(v):
      assert json.loads(json.dumps(v)) == v

★ ★★浮点和文本的默认行为(★必知★)★★:
  st.floats()   → ★会生成 nan、inf、-0.0、极小的次正规数★
  st.text()     → ★会生成 ""、"\x00"、emoji、★代理对(surrogate)★★
  ★ ★这是特性不是 bug——它在问:"你的代码处理过这些吗?"★
  ✓ 业务上不会出现就显式排除:
    st.floats(allow_nan=False, allow_infinity=False, min_value=0)
    st.text(alphabet=st.characters(blacklist_categories=("Cs",)))

策略是「如何生成数据」的描述。除了基础类型和容器,几个高价值的:st.builds / st.from_type 构造对象(后者从类型注解自动推导,还能 register_type_strategy 注册自定义类型)、@st.composite 处理有依赖关系的数据(先 draw 一个值,后面的生成依赖它)、st.recursive 生成 JSON 这类递归结构filter 要慎用——过滤率太低会触发 FailedHealthCheck能用约束表达就别用过滤min_value=1000001 而不是 filter(lambda x: x > 1000000))。必须知道 st.floats() 默认会生成 nan/infst.text() 会生成空串和代理对——这是特性不是 bug,它在问「你的代码处理过这些吗」,业务上确实不会出现就显式排除。

四、有状态测试

★ ★普通属性测试的局限★:
  ★ 只能测"单次调用"的性质
  ★ ✗ 测不了"一连串操作后的状态"
    - 购物车:加商品 → 改数量 → 删除 → 结算
    - 连接池:借出 → 归还 → 借出...
    - 状态机:各种转换序列

★ ★★RuleBasedStateMachine:生成操作序列★★:
  from hypothesis.stateful import (RuleBasedStateMachine, rule,
                                   invariant, precondition, Bundle)

  class ShoppingCartMachine(RuleBasedStateMachine):
      def __init__(self):
          super().__init__()
          self.cart = ShoppingCart()          # ★被测对象★
          self.model = {}                     # ★★简化的"模型"★★

      ★@rule★(sku=st.text(min_size=1), qty=st.integers(1, 10))
      def add_item(self, sku, qty):
          self.cart.add(sku, qty)
          self.model[sku] = self.model.get(sku, 0) + qty

      @rule(sku=st.text(min_size=1))
      ★@precondition(lambda self: self.model)★    # ★★只在有商品时执行★★
      def remove_item(self, sku):
          if sku in self.model:
              self.cart.remove(sku)
              del self.model[sku]

      ★@invariant()★                             # ★★每步之后都检查★★
      def total_matches_model(self):
          assert self.cart.item_count() == sum(self.model.values())

      @invariant()
      def total_never_negative(self):
          assert self.cart.total() >= 0

  ★TestCart = ShoppingCartMachine.TestCase★      # ★★pytest 会收集这个★★

★ ★★它做了什么★★:
  ① ★随机生成操作序列★:add → add → remove → add → ...
  ② ★每步之后检查所有 invariant★
  ③ ★失败时收缩到"最短的失败操作序列"★
     → ★★Falsifying example: add_item(sku='0', qty=1); remove_item(sku='0');
       add_item(sku='0', qty=1)  ← 三步就复现★★
  ★ ★这是手工测试几乎不可能覆盖的组合★

★ ★Bundle:在规则之间传递生成的对象★:
  class DbMachine(RuleBasedStateMachine):
      ★users = Bundle("users")★
      @rule(target=★users★, name=st.text())
      def create_user(self, name):
          u = db.create_user(name)
          return u                            # ★★放进 bundle★★
      @rule(user=★consumes(users)★)            # ★取出并移除★
      def delete_user(self, user):
          db.delete(user)
      @rule(user=★users★)                      # ★取出但保留★
      def rename(self, user): ...

★ ★模型对比(model-based testing)★:
  ★ 思路:★维护一个"明显正确但很慢"的简化模型★,
    每步之后对比真实实现和模型的状态
  ★ 例:
    - 真实:复杂的 LRU 缓存实现
    - 模型:★普通 dict + 一个 list 记录顺序★
  ★ ✓ ★能发现极其隐蔽的状态 bug★

★ ★适用场景★:
  ✓ ★状态机、缓存、连接池、事务、并发原语★
  ✓ ★有"一连串操作"语义的 API★
  ✗ ★纯函数(用普通 @given 就够)★
  ✗ ★涉及真实外部服务(太慢)★

普通属性测试只能测「单次调用」,测不了「一连串操作后的状态」——RuleBasedStateMachine 解决这个问题:@rule 定义可能的操作、@invariant 定义每步之后都该成立的性质、@precondition 限制操作的前置条件,Hypothesis 会随机生成操作序列并在失败时收缩到最短的失败序列(比如「三步就能复现」)——这是手工测试几乎不可能覆盖的组合Bundle 用于在规则之间传递生成的对象consumes 取出并移除、直接引用则保留)。最强的形态是「模型对比」:维护一个「明显正确但很慢」的简化模型(用 dict 模拟 LRU 缓存),每步之后对比真实实现和模型的状态——能发现极其隐蔽的状态 bug。适用于状态机、缓存、连接池、事务;纯函数用普通 @given 就够。

五、工程实践

★ ★★和 pytest 集成★★:
  ★ ✓ ★@given 装饰的函数就是普通的 pytest 测试★
  ★ ✗ ★不能和 @pytest.mark.parametrize 混用的坑★:
    可以叠加,但 ★Hypothesis 建议 given 在内层★
    @pytest.mark.parametrize("mode", ["a", "b"])
    @given(st.integers())
    def test_x(mode, n): ...
  ★ ✗ ★function 级 fixture 只会执行一次★(★重要!★)
    @given(st.integers())
    def test_x(db, n):        # ★★db fixture 只 setup 一次,
                              #   但测试体跑 100 次★★
    → ★★数据会在 100 次之间累积!★★
    ✓ 解法:
      ① ★在测试内部做清理★
      ② ★用 function_scoped_fixture 健康检查提示★
         @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
      ③ ★★更好:属性测试用纯函数,不碰数据库★★

★ ★★settings 配置★★:
  @settings(
      ★max_examples=100★,           # 默认 100
      ★deadline=200★,               # ★★单个例子的超时(毫秒),默认 200★★
      ★suppress_health_check=[...]★,
      ★derandomize=True★,           # ★固定随机(CI 上可复现)★
      ★print_blob=True★,            # 打印可复现的 blob
  )
  # ★profile:不同环境不同强度★
  settings.register_profile("dev", max_examples=20, deadline=None)
  settings.register_profile("ci", max_examples=500)
  settings.register_profile("nightly", max_examples=10000)
  settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))

★ ★★deadline 的坑★★:
  ★ 默认每个例子 200ms 超时 → ★慢函数会 DeadlineExceeded★
  ★ ✗ 而且 ★第一次运行可能因为 JIT/缓存慢★
  ✓ ★deadline=None 关掉★,或调大
  ★ ★但要警惕:它也在提醒你"这个函数是不是太慢了"★

★ ★★示例数据库(.hypothesis/)★★:
  ★ Hypothesis 会把★失败的输入存到 .hypothesis/examples★
  → ★下次运行优先重试这些★
  ★ ✓ ★CI 上缓存这个目录 → 已发现的 bug 会被持续验证★
  ★ ✗ ★不要提交到 git★(加进 .gitignore)
  ★ ★修复 bug 后要把关键反例固化成 @example★

★ ★★@example:把反例变成回归测试★★:
  # ★Hypothesis 报告了 Falsifying example: s=''★
  ★@example(s="")★                    # ★★固定下来,永远会测★★
  @given(st.text())
  def test_parse(s): ...
  ★ ✓ ★即使清了示例数据库,这个 case 也不会丢★
  ★ ★工作流:Hypothesis 发现 → 修复 → 加 @example 防回归★

★ ★运行时间的控制★:
  ★ 属性测试比示例测试慢 100 倍是正常的
  ✓ ★本地开发用低 max_examples(20)★
  ✓ ★CI 用中等(100~500)★
  ✓ ★nightly 用高强度(10000+)★
  ✓ ★用标记分离★:
    @pytest.mark.property
    @given(...)
    → pytest -m "not property" 快速反馈

★ ★★何时不该用属性测试★★:
  ✗ ★简单的 CRUD 和胶水代码★(没有有意义的属性)
  ✗ ★重 IO 的代码★(太慢)
  ✗ ★UI/端到端★
  ✗ ★属性想不出来时硬凑★(虚假的安全感)
  ✓ ★纯函数、算法、解析器、序列化、金额/日期计算、数据结构★
  ★ ★经验:它是"示例测试的补充",不是替代★
    ★示例测试负责"文档和典型场景",属性测试负责"边界和意外"★

工程实践里最容易踩的坑是「function 级 fixture 只会执行一次,但测试体跑 100 次」——数据会在 100 次之间累积,Hypothesis 会给出健康检查警告;最好的解法是「属性测试用纯函数、不碰数据库」deadline 默认每个例子 200ms,慢函数会 DeadlineExceeded——可以关掉,但它也在提醒你「这个函数是不是太慢了」示例数据库(.hypothesis/)会记住失败的输入并优先重试——CI 上缓存它、但不要提交到 git修复 bug 后要把关键反例固化成 @example,这样即使清了数据库也不会丢。运行时间靠 profile 分级(本地 20、CI 100~500、nightly 10000+)。何时不该用:简单 CRUD、重 IO、UI 测试、以及属性想不出来时硬凑——它是示例测试的补充而不是替代

六、实践清单

★ ★上手路径(★别一上来就全面铺开★)★:
  ① ★找一个纯函数(解析、格式化、计算)★
  ② ★写一个"往返"属性★
  ③ ★跑一遍,看它找到什么★
  ④ ★把找到的反例加成 @example★
  ⑤ ★扩展到相邻的函数★

★ 检查清单:
  【属性设计】
  □ ★属性独立于实现(不是把实现重写一遍)★
  □ ★属性不是"用被测逻辑验证被测逻辑"★
  □ ★属性足够强(不只是 is not None)★
  □ ★优先用往返/不变量/等价/元操作四类模板★
  【策略】
  □ ★floats 显式设 allow_nan/allow_infinity★
  □ ★text 需要时排除代理对★
  □ ★用约束而不是 filter(过滤率低会健康检查失败)★
  □ ★有依赖关系的数据用 @st.composite★
  【工程】
  □ ★不在属性测试里用 function 级的数据库 fixture★
  □ ★deadline 按实际调整★
  □ ★profile 分级(dev/ci/nightly)★
  □ ★.hypothesis 加进 .gitignore、CI 里缓存★
  □ ★发现的 bug 固化成 @example★
  □ ★用标记隔离(-m "not property" 快速反馈)★

★ ★典型适用场景排行★:
  ┌────────────────────────────┬──────────────────────┐
  │ ★序列化/解析器★             │ ★★往返属性,最佳场景★★│
  │ ★数据结构实现★              │ ★不变量 + 模型对比★   │
  │ ★算法(排序/搜索/图)★       │ ★不变量 + 等价性★     │
  │ ★金额/税率/折扣计算★         │ ★守恒、边界、单调性★  │
  │ ★日期/时区处理★             │ ★往返、边界(闰年、DST)★│
  │ ★状态机/缓存/连接池★         │ ★★RuleBasedStateMachine★★│
  │ ★重构/性能优化★             │ ★等价性(新旧对比)★  │
  │ CRUD / 胶水代码             │ ★不适合★             │
  └────────────────────────────┴──────────────────────┘

★ ★一个完整的实战例子★:
  # 被测:一个金额计算函数
  @given(
      items=st.lists(
          st.builds(Item,
                    price=st.decimals(min_value=0, max_value=10000, places=2),
                    qty=st.integers(1, 100)),
          min_size=1, max_size=20),
      discount_rate=st.decimals(min_value=0, max_value=1, places=4),
  )
  def test_total_properties(items, discount_rate):
      total = calculate_total(items, discount_rate)
      ★assert total >= 0★                              # 非负
      ★assert total <= sum(i.price * i.qty for i in items)★   # ★不超过原价★
      ★assert total.as_tuple().exponent >= -2★         # ★★保留两位小数★★
      # ★折扣为 0 时等于原价(边界)★
      if discount_rate == 0:
          assert total == sum(i.price * i.qty for i in items)

★ 一句话总结:
  ★"属性测试不列举『输入-输出』对,而是描述『输入的范围』和
    『结果应满足的性质』,让 Hypothesis 自动生成上百个输入去验证——
    它最有价值的是自动收缩到最小反例;
    四类属性模板是往返/不变量/等价/元操作,其中往返最好用;
    注意 floats 默认生成 nan、function 级 fixture 只执行一次;
    它是示例测试的补充而不是替代。"★

上手路径的关键是「别一上来就全面铺开」——找一个纯函数写一个往返属性,跑一遍看它找到什么。检查清单里三条最关键:属性要独立于实现floats 显式设 allow_nan不在属性测试里用 function 级的数据库 fixture。适用场景排行里,序列化和解析器是最佳场景(往返属性天然适用),重构和性能优化时用等价性属性守护也非常有价值。

记忆钩子:「★基于属性的测试不列举『具体输入和期望输出』,而是描述『输入应满足什么条件』和『结果应满足什么性质』,让框架自动生成大量随机输入验证★——Python 里是 Hypothesis 的 @given(st.xxx())。★它解决的是『人类写测试的系统性盲区』★:写测试的人和写代码的是同一个,★他想不到的边界测试里也不会有★(空值、极值、\x00、emoji、代理对、nan、超长)。★两个核心优势★:★① 系统性覆盖边界★(不是纯随机,会优先尝试 0/1/-1/空/极值);★② 自动收缩 shrinking——找到失败后自动把反例简化到最小★(从 8 个随机数收缩成 [0],★问题本质一眼可见★,这是它和纯随机测试的本质区别)。★四类属性模板按易用性排★:★往返(decode(encode(x))==x,最容易想到最有用,只要有一对互逆操作就能用)★、★不变量(排序后长度和元素集合不变、转账后总额守恒)★、★等价性(新旧实现对比,重构和优化时最有价值)★、★元操作关系(输入变一点输出该怎么变,没有参考实现时的救星)★。★属性写错的两种典型★:★用被测逻辑表达属性(assert my_sort(xs)==sorted(xs) 是在测标准库)★、★属性太弱(只断言 is not None)★。★必须知道的默认行为★:★st.floats() 会生成 nan/inf(而 nan != nan)★、★st.text() 会生成空串和 Unicode 代理对★——★这是特性不是 bug,它在问『你的代码处理过这些吗』★,业务上不会出现就显式排除。★filter 慎用★(过滤率低会 FailedHealthCheck),★能用约束就别用过滤★。★有状态测试用 RuleBasedStateMachine★:@rule 定义操作、@invariant 定义每步都该成立的性质、★失败时收缩到最短的失败操作序列★,最强形态是★模型对比(维护一个明显正确但慢的简化模型逐步对比)★。★工程上最容易踩的坑:function 级 fixture 只执行一次但测试体跑 100 次,数据会累积★——最好的解法是★属性测试只测纯函数、不碰数据库★。其他要点:★deadline 默认 200ms/例★、★.hypothesis 示例数据库会记住失败输入并优先重试(CI 缓存但不提交 git)★、★发现 bug 后固化成 @example 防回归★、★用 profile 分级(dev 20 / ci 500 / nightly 10000)★。★它是示例测试的补充而不是替代★——示例测试负责文档和典型场景,属性测试负责边界和意外。」

七、常见误区与追问

  • 误区:属性测试就是「随机生成一堆输入跑一遍」。 随机生成只是最表层的部分,真正的价值在「收缩」。纯随机测试找到失败时,给你的是一个混乱的输入——比如一个含 47 个随机整数的列表,你得自己花时间排查「到底是哪个特征触发了 bug」。而 Hypothesis 找到失败后会自动进入收缩阶段:不断尝试更简单的输入(更小的数、更短的列表、更简单的字符串),只要仍然失败就继续简化,最终报告给你的是能触发这个 bug 的最小输入——Falsifying example: xs=[0]。这一步把「排查时间」从几十分钟压缩到几秒钟。此外它还有两点不是纯随机的:生成时会优先尝试已知容易出问题的值(0、±1、边界值、空、特殊字符),以及会把历史上失败过的输入存进示例数据库、下次优先重试。所以准确的说法是「智能生成 + 自动最小化」。
  • 误区:有了属性测试就不需要写具体的示例测试了。 两者的作用不同,应该共存。示例测试(assert add(2, 3) == 5)的价值在于:① 文档性——读测试就知道这个函数怎么用、典型输入长什么样,而属性测试只描述抽象规律,新人看不出「这函数到底干嘛的」;② 具体的回归保护——某个历史 bug 对应的确切输入应该被永久固定下来;③ 快——CI 的快速反馈阶段跑不起几百个随机例子。属性测试的价值在于探索你没想到的输入。所以理想配比是:用示例测试覆盖典型场景和已知边界(作为文档和回归),用属性测试探索意外情况。而且两者能配合:Hypothesis 发现一个反例 → 你修复 bug → 把这个反例用 @example(...) 固化成永久的示例测试——这是最健康的工作流。
  • 误区:st.floats() 生成 nan 导致测试失败,是 Hypothesis 太苛刻。 它是在正确地提出一个你没考虑过的问题nan 有一个反直觉的性质:nan != nan,所以任何 assert result == expected 形式的断言在 nan 面前都会失败。但真正该问的是:你的生产代码遇到 nan 会怎样? 如果输入可能来自 JSON(Infinity)、来自浮点计算(0/0)、来自外部数据源,那么 nan 就是真实存在的风险——测试失败其实是在报告一个真实的漏洞。如果业务上确认不会出现(比如输入经过了校验),那就显式声明 st.floats(allow_nan=False, allow_infinity=False)——这个声明本身也是一份有价值的文档(它说明「我们假设输入不含 nan」)。同理 st.text() 默认会生成空串、\x00、emoji、Unicode 代理对(\ud800 这类无法编码成 UTF-8 的字符)——这些恰恰是编码处理、数据库写入、正则匹配最容易出 bug 的地方
  • 误区:在属性测试里用 pytest 的数据库 fixture,和普通测试一样。 会踩一个很隐蔽的坑:function 级 fixture 只执行一次,但测试体会跑上百次。因为 @given 装饰的函数在 pytest 看来只是一个测试用例——fixture 的 setup 和 teardown 各执行一次,而 Hypothesis 在这个用例内部循环调用测试体一百次。结果就是:第一次调用往数据库插的数据,会一直留到第一百次,测试之间互相污染,可能表现为「跑几十次后开始失败」或者「唯一约束冲突」。Hypothesis 会给出 function_scoped_fixture 的健康检查警告提醒你。三个解法:① 在测试体内部自己清理(每次开头 truncate);② 用 suppress_health_check 压制警告并接受累积(只在确实无害时);③ 最好的做法——让属性测试只覆盖纯函数、不碰数据库。数据库相关的验证交给普通的示例测试或集成测试。
  • 误区:属性写得越强越好,最好能完全确定输出。 属性太强就变成了「把实现重写一遍」,失去了独立验证的意义。极端的例子是 assert my_sort(xs) == sorted(xs)——这个「属性」实际上是拿标准库当参考实现,那你测的是 sorted 而不是你的逻辑;更糟的是 assert result == [x * 2 for x in xs]——你在测试里重新实现了一遍被测逻辑,两边写错同样的 bug 时测试照样通过。好的属性应该是从问题域本身推导出来的、独立于任何实现的规律:排序的属性是「输出有序」+「元素集合与输入相同」(这两条合起来唯一确定了正确性,但都不需要知道排序算法);金额计算的属性是「非负」+「不超过原价」+「保留两位小数」。判断标准:如果你把被测函数换成另一个完全不同的正确实现,这个属性还成立吗? 成立才是好属性。
  • 追问:想不出属性怎么办? 按顺序试这五个问题。① 「这个函数的输出,有什么是永远成立的?」——类型、范围、长度、非负、有序、总和守恒,这些「弱但真实」的属性也有价值。② 「有没有一个笨但明显正确的实现可以对比?」——如果你在优化一个算法,原来的朴素实现就是完美的参考(这叫 oracle 属性);重构时旧代码也是。③ 「输入变化一点,输出应该怎么变?」——这是元操作属性(metamorphic),不需要知道正确答案,只需要知道「关系」:加一个搜索条件结果只会变少、把图片旋转 90 度四次应该回到原样、把列表拼接起来排序等于分别排序后归并。④ 「有没有一对互逆的操作?」——往返属性,序列化/反序列化、编码/解码、存/取。⑤ 退而求其次——「不抛出预期之外的异常」+「返回值类型正确」+「不修改输入参数」,这对解析器和外部数据处理仍然很有价值。如果这五个都想不出来,说明这个函数可能不适合属性测试(比如纯粹的胶水代码),不必硬凑。
  • 追问:RuleBasedStateMachine 适合测什么? 它适合有「状态」且「操作序列」会影响结果的对象——普通的 @given 只能验证单次调用的性质,测不出「先 A 后 B 会出问题但先 B 后 A 没事」这类缺陷。典型场景:缓存(LRU 的淘汰逻辑在各种插入/访问/删除序列下是否正确)、连接池(借出/归还/超时/关闭的组合)、状态机(订单、工单的状态流转,特别是「非法转换应该被拒绝」)、事务和并发原语自己实现的数据结构(树、堆、跳表的各种操作组合)。用法是:@rule 定义可能的操作(Hypothesis 会随机组合成序列)、@invariant 定义每一步之后都该成立的性质@precondition 限制某操作的前置条件、Bundle 在规则之间传递生成的对象。最强的形态是「模型对比」——维护一个「明显正确但很慢」的简化实现(用 dict 加 list 模拟 LRU),每步之后断言真实实现和模型的状态一致。它的杀手锏是失败时会收缩到最短的操作序列,比如告诉你「只要连续 add、remove、add 同一个 key 就会出错」。
  • 追问:属性测试跑得太慢怎么办? 属性测试比示例测试慢一两个数量级是正常的(每个测试跑 100 次)。四个手段。① 用 profile 分级——settings.register_profile() 定义 dev(max_examples=20,本地开发够用)、ci(100~500)、nightly(10000+),通过环境变量切换;大部分 bug 在前 20 个例子就能被发现,高强度留给夜间。② 用标记隔离——给属性测试打 @pytest.mark.property,CI 的快速反馈阶段用 -m "not property" 跳过,只在完整阶段跑。③ 优化策略本身——filter 是主要的性能杀手(过滤率低时 Hypothesis 要生成大量样本才能凑够数),能用参数约束(min_value)表达的就不要用 filter;同时限制数据规模(max_size),生成 10 万元素的列表没有必要。④ 检查 deadline——默认每个例子 200ms 超时,如果频繁触发说明被测函数本身慢;可以调大或设 None但这个信号本身值得关注。最后提醒:属性测试只该覆盖纯函数,一旦碰数据库或网络,慢就是必然的。

八、加强记忆

基于属性的测试不列举「具体输入和期望输出」,而是描述「输入应满足什么条件」和「结果应满足什么性质」,让框架自动生成大量随机输入去验证——Python 里是 Hypothesis 的 @given(st.xxx())它解决的是「人类写测试的系统性盲区」:写测试的人和写代码的是同一个人,他想不到的边界,测试里也不会有(空值、极值、\x00、emoji、代理对、nan、超长输入)。两个核心优势① 系统性覆盖边界(不是纯随机,会优先尝试 0、±1、空、极值);② 自动收缩(shrinking)——找到失败后自动把反例简化到最小(从 8 个随机数收缩成 [0]问题本质一眼可见,这是它和纯随机测试的本质区别)。四类属性模板按易用性排序往返decode(encode(x)) == x,最容易想到也最有用,只要有一对互逆操作就能用)、不变量(排序后长度和元素集合不变、转账后总额守恒)、等价性(新旧实现对比,重构和优化时最有价值)、元操作关系(输入变一点输出该怎么变,没有参考实现时的救星)。属性写错有两种典型用被测逻辑表达属性assert my_sort(xs) == sorted(xs) 是在测标准库)、属性太弱(只断言 is not None)。必须知道的默认行为st.floats() 会生成 nan/inf(而 nan != nanst.text() 会生成空串和 Unicode 代理对——这是特性不是 bug,它在问「你的代码处理过这些吗」,业务上确实不会出现就显式排除。filter 要慎用(过滤率低会触发 FailedHealthCheck),能用参数约束就别用过滤有状态测试用 RuleBasedStateMachine@rule 定义操作、@invariant 定义每步都该成立的性质,失败时会收缩到最短的失败操作序列;最强形态是模型对比(维护一个明显正确但慢的简化模型逐步对比)。工程上最容易踩的坑是:function 级 fixture 只执行一次但测试体跑 100 次,数据会在其间累积——最好的解法是让属性测试只测纯函数、不碰数据库。其他要点:deadline 默认每个例子 200ms.hypothesis 示例数据库会记住失败输入并优先重试(CI 里缓存但不提交 git)发现 bug 后固化成 @example 防回归用 profile 分级(dev 20 / ci 500 / nightly 10000)。最后记住:它是示例测试的补充而不是替代——示例测试负责文档和典型场景,属性测试负责边界和意外。