传统用例测试是在"回答几个已知的问题",属性测试则是在"让机器自己生成成千上万个问题"。 你只描述一段代码"必须永远满足的性质"(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、每个反例最小化后固化为回归用例、种子可复现。当你的核心函数都能用"永远成立的性质"来守护时,测试质量就从"覆盖了几个例子"跃升到"验证了一条规律"。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。