属性测试实战:用不变式与自动生成让测试拥有无限边界

深入讲解属性测试(Property-Based Testing)方法论:与传统用例测试的本质区别、不变式与测试性质的设计、Hypothesis/QuickCheck/rapid 等工具实战、反例最小化(shrinking)、状态机属性测试、自定义数据生成策略,以及属性测试的适用边界与团队落地。

传统用例测试是在"回答几个已知的问题",属性测试则是在"让机器自己生成成千上万个问题"。 你只描述一段代码"必须永远满足的性质"(invariant),工具随机生成数据去攻击它——一旦发现违反性质的输入,自动缩小到最小反例交给你。测试的边界从此不再由你的想象力决定。


一、传统用例测试的盲区

1.1 边界在"案例"而不是"性质"

def add_tax(price: float, rate: float) -> float:
    return price * (1 + rate)

# 传统写法:测试几个精心挑选的值
def test_add_tax_examples():
    assert add_tax(100, 0.1) == 110.0
    assert add_tax(0, 0.2) == 0.0
    # 覆盖了吗?负数?NaN?超大值?浮点精度?——没有

ℹ️ 核心洞察:用例测试验证的是"你想到的几个例子",而 bug 往往出现在你没想到的组合里。属性测试把"应该恒成立的规律"写下来,让工具自动扫遍组合空间。

1.2 用例测试的三个盲区

盲区一:组合爆炸
  3 个参数 × 每种取值边界 = 你只能手写 20 个用例,组合有 10^9 种

盲区二:思维定势
  你会倾向测试"正常"的值,而 bug 常在极端/畸形输入里

盲区三:回归脆弱
  加一个参数就要加一组用例,测试与案例数量线性增长

二、属性测试基本原理

2.1 一个属性 = 一段不变式

属性测试的三步:
  1. 描述性质:对"任意输入 X",程序输出应该满足某规律
  2. 随机生成:工具自动产生大量 X(含边界、畸形值)
  3. 断言性质:对每个 X 运行并验证,发现违背即"失败"

典型性质模板:
  · 幂等性:   f(f(x)) == f(x)          (去重、清理)
  · 反比性:   逆运算恢复原值:dec(enc(x)) == x
  · 保序性:   若 a < b 则 f(a) <= f(b)  (排序、过滤)
  · 守恒性:   分组后 sum 等于整体 sum
  · 对称性:   操作可交换:f(a,b) == f(b,a)
  · 边界不崩溃:任何输入都不抛异常 / 不返回 NaN

2.2 反例最小化(Shrinking)

Shrinking 是属性测试的杀手锏:
  发现 f(0.00001, -3.2e-7, "a"*90) 违反性质后,
  工具自动尝试更小的输入,逐步缩小反例:
    → f(1.0, -1.0, "a") 也违反?
    → 继续缩 → 最终给一个"最小反例"
      例如 add_tax 的最小反例:add_tax(-1.0, 0.5) == -1.5(应为非负)

价值:拿到最小的、最易理解的反例,比一堆随机输入好调试得多

三、Hypothesis(Python)实战

3.1 基本属性测试

from hypothesis import given, strategies as st, assume, settings

@st.decorators.rule
@given(st.floats(allow_nan=False), st.floats(min_value=0, allow_nan=False))
@settings(max_examples=500)
def test_price_never_negative(price, rate):
    # 性质:带税价格永远 >= 原价(rate 非负时)
    result = add_tax(price, rate)
    assert result >= price, f"price={price} rate={rate} -> {result}"

3.2 失败反例的自动缩小

第一次运行失败输出(Hypothesis 自动 shrinking):
  Falsifying example: test_price_never_negative(
      price=-1000.0, rate=0.1)
  当 price 为负时,result = -1100.0 < -1000,违反性质
  → 反例被缩小到:(-1000.0, 0.1)
  → 进一步缩小尝试 (-1.0, 0.1):result=-1.1 < -1.0 仍违反
  → 最终最小反例:price=-1.0, rate=0.1

启示:性质本身暴露了设计问题——"价格不该为负"或"函数该处理负数"

3.3 常用策略(Strategies)

import hypothesis.strategies as st

# 基础数据
st.integers(min_value=1, max_value=100)      # 受限整数
st.text(min_size=1, max_size=20)              # 字符串
st.lists(st.integers(), max_size=10)          # 整数列表
st.dictionaries(st.text(), st.integers())     # 字典
st.sampled_from(["PAID", "PENDING", "CANCELLED"])  # 枚举值
st.builds(Order, id=st.integers(), status=st.text()) # 对象

# 组合与约束
@given(st.lists(st.integers()))
def test_sorted_keeps_elements(xs):
    s = sorted(xs)
    assert sorted(s) == s                      # 幂等
    assert set(s) == set(xs)                   # 元素守恒

# assume 过滤前置条件
@given(st.integers())
def test_division(x):
    assume(x != 0)                             # 跳过 x==0
    assert 10 / x * x == 10

四、跨语言工具链

4.1 Haskell QuickCheck(鼻祖)

import Test.QuickCheck

-- 性质:reverse 是幂等的
prop_reverseIdempotent :: [Int] -> Bool
prop_reverseIdempotent xs = reverse (reverse xs) == xs

-- 运行:自动生成 100 个随机列表验证
main :: IO ()
main = quickCheck prop_reverseIdempotent
-- 输出:+++ OK, passed 100 tests.

4.2 Go:rapid / gopter

// go test 里用 rapid 做属性测试
import "pgregory.net/rapid"

func TestOrderAmountConserved(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        amount := rapid.Float64Range(1, 10000).Draw(t, "amount")
        discount := rapid.Float64Range(0, 1).Draw(t, "discount")
        got := Charge(amount, discount)
        want := amount * (1 - discount)
        if math.Abs(got-want) > 1e-6 {
            t.Fatalf("amount=%v discount=%v got=%v want=%v",
                amount, discount, got, want)
        }
    })
}

4.3 其他语言生态

· Rust:proptest(proptest! 宏 + .prop_assert)、quickcheck(旧)
· Java/Kotlin:jqwik(JUnit 5 集成)、junit-quickcheck
· C#:FsCheck(属性测试的 .NET 实现)
· JavaScript/TS:fast-check、jsverify
· Scala:ScalaCheck(内部 DSL 属性定义)

选型要点:
  优先选与测试框架深度集成的(如 jqwik 之于 JUnit5)
  看 shrinking 质量与输出可读性(fast-check/proptest 较优)

五、状态机属性测试(Stateful Property Testing)

5.1 为什么需要状态机

纯函数属性测试覆盖"输入→输出";
但有状态系统(缓存、数据库、队列)需要验证"操作序列下的不变式":

  任何合法的操作序列之后:
  · 不变量仍然成立(如余额 >= 0、队列长度守恒)
  · 系统不会崩溃/进入非法状态

状态机属性测试:工具随机生成"操作序列",
每一步执行后验证模型与实际系统的一致性。

5.2 模型基准(Model-based Testing)

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

class StackMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.stack = []
        self.model = []              # 模型:参考实现

    @rule()
    def push(self, v=st.integers()):
        self.stack.append(v)
        self.model.append(v)

    @precondition(lambda self: self.model)
    @rule()
    def pop(self):
        assert self.stack.pop() == self.model.pop()   # 一致!
执行流程:
  随机生成操作序列:[push(3), push(1), pop(), push(9), ...]
  每一步:执行真实实现 → 与模型对比 → 不变量断言
  若发现不一致 → 缩小到最小"操作序列反例":
    例如 [push(1), push(2), pop(), pop(), pop()](多弹一次崩溃)

六、数据生成策略与自定义生成器

6.1 让数据"像生产数据"

import hypothesis.strategies as st

# 组合现实约束:金额 + 状态 的合法组合
valid_order = st.builds(
    Order,
    id=st.integers(min_value=1),
    status=st.sampled_from(["PENDING", "PAID", "CANCELLED"]),
    amount=st.decimals(min_value=0, max_value=10**6),
)

@given(valid_order)
def test_order_validation(order):
    assert order.amount >= 0
    assert order.status in ("PENDING", "PAID", "CANCELLED")

6.2 生成器的常见陷阱

陷阱一:生成范围太小
  st.integers() 默认包含负数和超大值——很好,别怕
  若你只测 int(0, 100),边界和溢出就测不到

陷阱二:误用 assume 当过滤器
  assume 会丢弃大量样本(慢),能用策略约束就先约束
  → 用 min_value/max_value/filter 优先

陷阱三:忽略种子复现
  属性测试是随机的——必须能复现失败样本
  保存种子(Hypothesis 的 .hypothesis/examples 数据库,
  pytest -x --hypothesis-seed=... 复现)

七、在 CI 中落地与复现

7.1 样本量与时间控制

from hypothesis import settings, given
import hypothesis.strategies as st

# 开发本地:小样本快速迭代;CI:大样本充分挖掘
@settings(max_examples=1000, deadline=5000)   # CI 用
def test_ci(): ...

@settings(max_examples=100, deadline=None)    # 本地快速
def test_dev(): ...

7.2 反例的持久化与回归

· Hypothesis 把每个失败反例存进 .hypothesis/examples
· 修复后再次运行会"重放"历史反例(回归守护)
· 把最小反例固化为普通单元测试:
    assert add_tax(-1.0, 0.1) 行为符合约定

· CI 中设置 --hypothesis-seed 复现 + --hypothesis-show-statistics
  查看生成覆盖率(死代码提示)
# GitHub Actions 属性测试步骤
- run: pytest -q tests/property --hypothesis-seed=${{ github.run_id }}
# 失败时带上种子信息,方便本地复现

7.3 与其他测试互补

· 属性测试 ≠ 替代用例测试:用例测试表达"业务意图",属性测试表达"规律"
· 组合:核心函数先写属性(不变式),再补少量场景用例
· 集成测试(Testcontainers)跑真实 DB;属性测试验证纯逻辑
· 常与模糊测试配合:属性测试面向"逻辑性质",模糊测试面向"崩溃/安全"

八、适用边界:什么时候别用属性测试

场景判断替代
简单纯函数,输入域很小用几个用例就够单元测试
UI/视觉输出性质难描述"好不好看"快照/视觉回归
依赖大量外部状态生成复杂且慢集成测试 + Testcontainers
业务场景语义强用例更能表达意图用例测试 + 少量属性
解析/编解码/序列化非常合适(幂等性)属性测试
排序/聚合/去重/校验非常合适(守恒性)属性测试

ℹ️ 甜点区:一切有"明确数学性质"的代码——编解码、序列化、排序、校验、状态转换、数值计算——都是属性测试的主场。


九、实践清单与避坑

9.1 Checklist

□ 从"性质"出发而非"案例":先想清楚不变量
□ 优先用策略约束数据,少用 assume 过滤
□ 覆盖边界:空集合、0、NaN、负值、超长字符串
□ 状态机测试覆盖有状态核心逻辑
□ 保存/复现反例(种子 + examples 库)
□ 把最小反例固化为普通回归用例
□ CI 中大样本、本地小样本(settings 双配置)
□ 检查生成覆盖率(show-statistics)防止死代码
□ 与用例测试、集成测试合理分工
□ 逐步引入:先挑编解码/校验/排序这类"性质明确"的函数

9.2 常见坑

坑现象对策
性质写得像实现测试永不失败性质应独立于实现推导
用 assume 过多测试极慢、样本浪费改用策略约束
忽略种子反例无法复现保存种子 + examples
只测快乐路径边界/畸形没测到放宽生成范围
状态机不设前置非法序列报错而非断言@precondition 限制合法操作
样本太少偶然通过、覆盖不足提高 max_examples

9.3 一句话原则

用例测试验证"你知道的",属性测试发现"你不知道的"——把测试交给机器去探索。

总结:属性测试决策表

环节关键动作
定位用不变式描述规律,工具自动生成输入验证
性质幂等/守恒/保序/对称/边界不崩溃
反例Shrinking 最小化 → 固化为回归用例
工具Hypothesis(py)/QuickCheck(hs)/rapid(go)/proptest(rs)/jqwik(java)
状态模型基准 + 操作序列 + @precondition
数据策略约束优于 assume,贴合生产数据形状
落地种子复现 + CI 大样本 + 历史反例重放

属性测试把测试从"人工列举案例"升级为"机器持续探索边界"。它不会取代用例测试,而是补上用例测试最脆弱的一环——你的想象力之外的输入空间。落地守住四件事:先写性质再写工具、用策略约束而非 assume、每个反例最小化后固化为回归用例、种子可复现。当你的核心函数都能用"永远成立的性质"来守护时,测试质量就从"覆盖了几个例子"跃升到"验证了一条规律"。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「testing」更多文章

  1. 模糊测试实战:覆盖率引导的自动化漏洞挖掘与 CI 落地
  2. 数据库测试与 Schema 变更安全网:迁移、数据层与数据管道的验证实践
  3. 并行测试执行与 Flaky Test 治理:从变慢变脆到稳定高效