31d - AI辅助PBT
本文是《AI Agent 实战手册》第 31 章第 4 节。 上一节:31c-需求到测试用例自动化 | 下一节:31e-测试Steering规则与反模式
概述
Property-Based Testing(PBT,基于属性的测试)是 AI 辅助测试中最具杠杆效应的技术之一。与传统单元测试逐个验证具体示例不同,PBT 通过定义”对所有有效输入都应成立的属性”,让框架自动生成成百上千个随机输入来验证代码正确性。在 AI Agent 辅助开发的 2025-2026 年,PBT 的价值更加凸显:AI 可以从 Spec 文档中自动发现属性、设计智能生成器、优化收缩策略,将原本需要深厚测试经验的 PBT 编写过程大幅简化。本节深入覆盖 AI 辅助 PBT 的完整工作流——从属性发现、生成器设计、框架选型到收缩优化和反例分析。
💡 本节与第 18 章 18d-PBT深度指南 互补:18d 侧重 PBT 框架本身的深度讲解,本节侧重 AI Agent 如何辅助 PBT 的全流程——从 Spec 发现属性、AI 生成生成器、到 AI 分析反例。
1. PBT 核心概念回顾
1.1 为什么 AI 时代更需要 PBT
AI 生成的代码面临三个独特挑战,PBT 恰好能应对:
| 挑战 | 传统测试的局限 | PBT 的优势 |
|---|---|---|
| AI 在边缘场景犯错 | 人工难以穷举所有边缘输入 | 自动生成包含边界值、空值、极端值的输入 |
| AI 代码的”看起来对”陷阱 | 示例测试可能恰好避开了 bug | 随机输入能探索到人类未预见的失败路径 |
| 需求理解偏差 | 测试和代码可能犯同样的理解错误 | 属性定义迫使你从”应该满足什么”角度思考 |
| 重构后的回归风险 | AI 重构可能引入微妙的行为变化 | 属性测试不依赖具体实现,重构后仍然有效 |
1.2 PBT 的核心工作流
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 定义属性 │ │ 设计生成器 │ │ 运行测试 │
│ (Property) │────▶│ (Generator) │────▶│ (Execute) │
└──────────────┘ └──────────────┘ └──────────────┘
▲ │
│ 通过? │ 失败?
│ ┌──────┴──────┐
│ ▼ ▼
│ 增加样本数 ┌──────────┐
│ 继续测试 │ 收缩 │
│ │ (Shrink) │
│ └────┬─────┘
│ ▼
│ 最小反例
│ (Minimal Counterexample)
│ │
└───────────── AI 分析反例 ◄────────────────────┘
调整属性/修复代码1.3 七种常见属性模式
| 模式 | 描述 | 典型示例 | AI 发现难度 |
|---|---|---|---|
| 往返(Roundtrip) | encode 后 decode 得到原值 | decode(encode(x)) == x | ⭐ 低 |
| 幂等(Idempotent) | 操作多次与一次结果相同 | sort(sort(xs)) == sort(xs) | ⭐ 低 |
| 不变量(Invariant) | 操作后某条件始终成立 | len(filter(xs)) <= len(xs) | ⭐⭐ 中 |
| 等价/Oracle | 两种实现产生相同结果 | fastSort(xs) == referenceSort(xs) | ⭐⭐ 中 |
| 变形(Metamorphic) | 输入变换后输出满足已知关系 | sort(reverse(xs)) == sort(xs) | ⭐⭐⭐ 高 |
| 归纳(Inductive) | 小问题的解可组合为大问题的解 | sum(xs ++ ys) == sum(xs) + sum(ys) | ⭐⭐⭐ 高 |
| 统计(Statistical) | 大量样本的统计特征满足预期 | 随机数生成器的均匀分布 | ⭐⭐⭐⭐ 极高 |
2. AI 辅助属性发现
2.1 从 Spec 文档发现属性
在 Spec 驱动的开发工作流中(如 Kiro 的 requirements.md → design.md → tasks.md),AI 可以系统性地从需求文档中提取可测试的属性。这是 AI 辅助 PBT 最核心的能力。
属性发现的四层模型
┌─────────────────────────────────────────────────┐
│ 第 1 层:需求文档 (requirements.md) │
│ "用户注册时,邮箱必须唯一" │
│ → 属性:对任意两个成功注册的用户,email 不相同 │
├─────────────────────────────────────────────────┤
│ 第 2 层:设计文档 (design.md) │
│ "排序算法使用归并排序,时间复杂度 O(n log n)" │
│ → 属性:输出有序 + 长度不变 + 元素集合不变 │
├─────────────────────────────────────────────────┤
│ 第 3 层:API 契约 (OpenAPI/GraphQL Schema) │
│ "GET /users/{id} 返回 200 或 404" │
│ → 属性:对任意有效 id,状态码 ∈ {200, 404} │
├─────────────────────────────────────────────────┤
│ 第 4 层:代码实现 (源代码) │
│ 函数签名 + 类型约束 + 注释 │
│ → 属性:类型不变量、前置/后置条件 │
└─────────────────────────────────────────────────┘从验收标准到属性的转换规则
| 验收标准模式 | 属性类型 | 示例 |
|---|---|---|
| ”WHEN X, THEN Y” | 条件不变量 | ∀ input: if precondition(input) then postcondition(f(input)) |
| ”必须唯一” | 唯一性属性 | ∀ a, b: f(a) == f(b) → a == b |
| ”不超过 N” | 边界属性 | ∀ input: result <= N |
| ”格式为 XXX” | 格式属性 | ∀ input: matches(f(input), pattern) |
| ”A 和 B 结果相同” | 等价属性 | ∀ input: f_a(input) == f_b(input) |
| ”操作可撤销” | 往返属性 | ∀ input: undo(do(input)) == input |
2.2 AI 属性发现的 Prompt 模板
模板 1:从需求文档发现属性
你是一位 Property-Based Testing 专家。请分析以下需求文档,
为每个验收标准识别可测试的属性。
## 需求文档
[粘贴 requirements.md 内容]
## 输出格式
对每个验收标准,请提供:
1. 验收标准编号和描述
2. 是否可作为 PBT 属性测试(是/否/部分)
3. 如果可以,写出属性的形式化描述
4. 建议的生成器策略(输入空间如何约束)
5. 预期的边缘场景
请用以下格式输出:
### AC [编号]: [描述]
- **可测试性**: [是/否/部分]
- **属性**: ∀ [变量]: [属性描述]
- **生成器**: [生成器策略]
- **边缘场景**: [列表]模板 2:从代码签名发现属性
分析以下函数签名和文档注释,识别所有可测试的属性。
重点关注:
1. 往返属性(如果存在逆操作)
2. 幂等属性(重复调用是否等价)
3. 不变量(输出必须满足的条件)
4. 单调性(输入增大时输出的变化方向)
5. 分配律/结合律等代数属性
## 代码
[粘贴函数签名和注释]
对每个发现的属性,请提供:
- 属性名称和形式化描述
- 对应的 [框架名] 测试代码
- 推荐的生成器约束模板 3:从 Kiro Design 文档发现正确性属性
你是 Kiro Spec 驱动开发的 PBT 专家。请分析以下 design.md
中的 Correctness Properties 部分,将每个属性转化为可执行的
Property-Based Test。
## Design 文档
[粘贴 design.md 的 Correctness Properties 部分]
## 目标框架
[fast-check / Hypothesis / proptest / Go rapid]
## 输出要求
对每个属性:
1. 属性的自然语言描述
2. 形式化表达
3. 完整的测试代码(包含生成器定义)
4. 预期的收缩行为
5. 与 requirements.md 的可追溯性链接2.3 属性发现的系统化方法
AI 在发现属性时,应遵循以下系统化检查清单:
属性发现检查清单:
□ 往返属性
- 是否存在 encode/decode、serialize/deserialize、compress/decompress 对?
- 是否存在 create/delete、push/pop、enqueue/dequeue 对?
□ 幂等属性
- 排序、去重、格式化、规范化操作是否幂等?
- HTTP PUT/DELETE 是否幂等?
□ 不变量
- 集合操作后大小关系是否成立?(filter 后 ≤ 原长度)
- 数值操作后范围是否成立?(百分比 ∈ [0, 100])
- 类型约束是否始终满足?(非空、非负、有效枚举值)
□ 等价/Oracle 属性
- 是否存在简单但慢的参考实现?
- 是否存在已知正确的第三方库可对比?
□ 变形属性
- 输入排列是否影响输出?(排序不受输入顺序影响)
- 输入缩放是否有已知的输出关系?
□ 保持属性
- 操作是否保持元素集合不变?(排序不增删元素)
- 操作是否保持某种度量不变?(旋转保持距离)
□ 交换律/结合律
- 二元操作是否满足交换律? f(a, b) == f(b, a)
- 二元操作是否满足结合律? f(f(a, b), c) == f(a, f(b, c))工具推荐
| 工具 | 用途 | 价格 | 适用场景 |
|---|---|---|---|
| Kiro Specs | 从 requirements/design 文档提取属性 | 免费(Kiro 内置) | Spec 驱动的 PBT 工作流 |
| Claude Code | 分析代码签名,生成属性和测试 | $20/月(Pro) | 复杂业务逻辑的属性发现 |
| Cursor | 内联属性建议和测试生成 | $20/月(Pro) | 编码时实时属性发现 |
| GitHub Copilot | 基于上下文的属性补全 | $10/月(Individual) | 简单属性的快速补全 |
| ChatGPT + Canvas | 交互式属性探索和可视化 | $20/月(Plus) | 学习和探索属性模式 |
3. 生成器设计
3.1 生成器设计原则
生成器(Generator/Arbitrary/Strategy)是 PBT 的核心——它决定了测试能探索多大的输入空间。好的生成器应该:
- 覆盖边界值:空集合、零值、最大值、最小值
- 约束到有效输入空间:不生成无意义的输入(如负数年龄)
- 偏向”有趣”的值:更频繁地生成容易触发 bug 的值
- 支持高效收缩:失败时能快速缩小到最小反例
3.2 各框架的生成器 API 对比
| 能力 | fast-check (JS/TS) | Hypothesis (Python) | proptest (Rust) | rapid (Go) | QuickCheck (Haskell) |
|---|---|---|---|---|---|
| 基础类型生成 | fc.integer() | st.integers() | any::<i32>() | rapid.Int() | arbitrary |
| 范围约束 | fc.integer({min, max}) | st.integers(min, max) | 0..100i32 | rapid.IntRange(0, 100) | choose(0, 100) |
| 字符串生成 | fc.string() | st.text() | "[a-z]+" (正则) | rapid.String() | arbitrary |
| 集合生成 | fc.array() | st.lists() | prop::collection::vec() | rapid.SliceOf() | listOf |
| 组合生成器 | fc.record() | st.fixed_dictionaries() | (arb1, arb2) | 结构体字段 | liftA2 |
| 条件过滤 | .filter() | .filter() | prop_filter! | .Filter() | suchThat |
| 映射变换 | .map() | .map() | prop_map! | .Map() | fmap |
| 扁平映射 | .chain() | .flatmap() | prop_flat_map! | — | >>= |
| 自定义收缩 | 内置自动收缩 | 内置自动收缩 | 内置自动收缩 | 内置自动收缩 | 需手动实现 |
3.3 智能生成器设计模式
模式 1:分层生成(Layered Generation)
先生成结构骨架,再填充细节。适用于复杂嵌套数据结构。
fast-check 示例:
import fc from 'fast-check';
// 生成一个有效的电商订单
const orderArbitrary = fc.record({
id: fc.uuid(),
customer: fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
}),
// 先生成 1-10 个商品项
items: fc.array(
fc.record({
productId: fc.uuid(),
name: fc.string({ minLength: 1 }),
price: fc.integer({ min: 1, max: 1_000_000 }), // 分为单位
quantity: fc.integer({ min: 1, max: 99 }),
}),
{ minLength: 1, maxLength: 10 }
),
// 折扣率 0-50%
discountPercent: fc.integer({ min: 0, max: 50 }),
}).map(order => ({
...order,
// 计算总价(派生字段)
total: order.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
) * (100 - order.discountPercent) / 100,
}));
// 属性:订单总价始终非负
fc.assert(
fc.property(orderArbitrary, (order) => {
return order.total >= 0;
})
);模式 2:状态机生成(Stateful Generation)
生成一系列操作序列,验证状态转换的正确性。
Hypothesis 示例:
from hypothesis import given, settings
from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
class ShoppingCartMachine(RuleBasedStateMachine):
"""购物车状态机测试"""
def __init__(self):
super().__init__()
self.cart = ShoppingCart()
self.expected_items: dict[str, int] = {}
@rule(
product_id=st.text(min_size=1, max_size=20),
quantity=st.integers(min_value=1, max_value=10)
)
def add_item(self, product_id: str, quantity: int):
"""添加商品到购物车"""
self.cart.add(product_id, quantity)
self.expected_items[product_id] = (
self.expected_items.get(product_id, 0) + quantity
)
@rule(product_id=st.text(min_size=1, max_size=20))
def remove_item(self, product_id: str):
"""从购物车移除商品"""
self.cart.remove(product_id)
self.expected_items.pop(product_id, None)
@invariant()
def items_match(self):
"""不变量:购物车内容与预期一致"""
for pid, qty in self.expected_items.items():
assert self.cart.get_quantity(pid) == qty
@invariant()
def total_non_negative(self):
"""不变量:总价始终非负"""
assert self.cart.total() >= 0
# 运行状态机测试
TestShoppingCart = ShoppingCartMachine.TestCase模式 3:约束求解生成(Constraint-Based Generation)
当输入之间存在复杂约束关系时,使用 flatmap/chain 确保生成的数据满足约束。
fast-check 示例:
// 生成一个有效的日期范围(start <= end)
const dateRangeArbitrary = fc
.tuple(
fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }),
fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') })
)
.map(([a, b]) => a <= b ? { start: a, end: b } : { start: b, end: a });
// 生成一个有效的分页请求(offset + limit <= total)
const paginationArbitrary = fc
.integer({ min: 1, max: 10000 }) // total
.chain(total =>
fc.tuple(
fc.constant(total),
fc.integer({ min: 0, max: total }), // offset
fc.integer({ min: 1, max: 100 }) // limit
)
)
.map(([total, offset, limit]) => ({ total, offset, limit }));模式 4:领域特定生成器(Domain-Specific Generators)
为特定业务领域构建可复用的生成器库。
Go rapid 示例:
package generators
import (
"pgregory.net/rapid"
"testing"
)
// 生成有效的中国手机号
func PhoneNumber() *rapid.Generator[string] {
prefixes := []string{"130", "131", "132", "133", "134", "135",
"136", "137", "138", "139", "150", "151", "152", "153",
"155", "156", "157", "158", "159", "186", "187", "188", "189"}
return rapid.Custom(func(t *rapid.T) string {
prefix := rapid.SampledFrom(prefixes).Draw(t, "prefix")
suffix := rapid.StringMatching(`[0-9]{8}`).Draw(t, "suffix")
return prefix + suffix
})
}
// 生成有效的 IPv4 地址
func IPv4Address() *rapid.Generator[string] {
return rapid.Custom(func(t *rapid.T) string {
octets := make([]int, 4)
for i := range octets {
octets[i] = rapid.IntRange(0, 255).Draw(t, "octet")
}
return fmt.Sprintf("%d.%d.%d.%d",
octets[0], octets[1], octets[2], octets[3])
})
}
// 生成有效的金额(分为单位,避免浮点精度问题)
func MoneyInCents(min, max int64) *rapid.Generator[int64] {
return rapid.Int64Range(min, max)
}
// 属性测试:手机号格式验证
func TestPhoneNumberFormat(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
phone := PhoneNumber().Draw(t, "phone")
if len(phone) != 11 {
t.Fatalf("手机号长度应为 11,实际为 %d: %s", len(phone), phone)
}
})
}3.4 AI 辅助生成器设计的 Prompt 模板
你是一位 PBT 生成器设计专家。请为以下数据类型设计智能生成器。
## 数据类型定义
[粘贴类型定义 / 接口 / struct]
## 业务约束
[列出字段间的约束关系,如 start_date < end_date]
## 目标框架
[fast-check / Hypothesis / proptest / rapid]
## 要求
1. 生成器必须只产生满足所有业务约束的有效数据
2. 偏向生成边界值(空字符串、零值、最大值)
3. 支持高效收缩(优先使用 map/chain 而非 filter)
4. 提供生成器的单元测试,验证生成的数据确实满足约束
5. 如果存在可复用的子生成器,请提取为独立函数操作步骤
步骤 1:分析数据类型和约束
让 AI 分析目标数据类型的所有字段、类型约束和业务规则:
请分析以下 TypeScript 接口,列出:
1. 每个字段的类型约束(范围、格式、可选性)
2. 字段间的依赖关系(如 endDate > startDate)
3. 隐含的业务规则(如 discount 不能超过 total)
4. 边界值列表(每个字段的极端有效值)
interface Order {
id: string;
items: OrderItem[];
discount: number;
total: number;
status: 'pending' | 'paid' | 'shipped' | 'delivered';
createdAt: Date;
updatedAt: Date;
}步骤 2:选择生成策略
根据约束复杂度选择合适的生成策略:
| 约束类型 | 推荐策略 | 示例 |
|---|---|---|
| 无约束 | 直接使用内置生成器 | fc.string() |
| 范围约束 | 参数化内置生成器 | fc.integer({min: 0, max: 100}) |
| 格式约束 | 正则或自定义生成器 | fc.stringMatching(/^[A-Z]{2}\d{6}$/) |
| 字段间依赖 | chain/flatmap | 先生成 start,再基于 start 生成 end |
| 复杂业务规则 | 自定义 Arbitrary + map | 生成原始数据后 map 到有效状态 |
| 状态依赖 | 状态机生成器 | Hypothesis StatefulTesting |
步骤 3:实现并验证生成器
// 验证生成器本身的正确性
fc.assert(
fc.property(orderArbitrary, (order) => {
// 生成器应该只产生有效订单
return (
order.items.length >= 1 &&
order.total >= 0 &&
order.createdAt <= order.updatedAt
);
}),
{ numRuns: 10000 } // 大量运行验证生成器质量
);4. PBT 框架对比与选型
工具推荐
| 框架 | 语言 | 用途 | 价格 | 适用场景 |
|---|---|---|---|---|
| fast-check | JS/TS | 前端和 Node.js 的 PBT | 免费(MIT) | React/Vue/Node.js 项目 |
| Hypothesis | Python | Python 生态最成熟的 PBT | 免费(MPL 2.0) | Django/FastAPI/数据处理 |
| proptest | Rust | Rust 生态主流 PBT | 免费(MIT/Apache) | Rust 系统编程 |
| rapid | Go | Go 现代 PBT 库 | 免费(MPL 2.0) | Go 微服务/CLI 工具 |
| QuickCheck | Haskell | PBT 鼻祖 | 免费(BSD-3) | Haskell/函数式编程 |
| jqwik | Java/Kotlin | JVM 生态 PBT | 免费(EPL 2.0) | Spring Boot/企业级 Java |
| testing/quick | Go | Go 标准库 PBT | 免费(Go 标准库) | 简单属性测试(功能有限) |
| pbt | Rust | Rust 新一代 PBT(2025) | 免费(MIT) | 需要更好收缩的 Rust 项目 |
框架选型决策树
你的项目使用什么语言?
│
├── JavaScript/TypeScript
│ └── → fast-check(唯一成熟选择,生态完善)
│
├── Python
│ └── → Hypothesis(事实标准,支持状态机测试)
│
├── Rust
│ ├── 需要 Hypothesis 风格的策略组合?
│ │ └── → proptest(最成熟,75M+ 下载)
│ └── 需要更好的收缩?
│ └── → pbt(2025 新库,收缩优于 proptest)
│
├── Go
│ ├── 简单属性测试?
│ │ └── → testing/quick(标准库,零依赖)
│ └── 需要完整 PBT 功能?
│ └── → rapid(现代设计,自动收缩)
│
├── Java/Kotlin
│ └── → jqwik(JUnit 5 集成,功能完整)
│
└── Haskell
└── → QuickCheck(PBT 鼻祖,生态最丰富)各框架的 AI 友好度评估
| 维度 | fast-check | Hypothesis | proptest | rapid | QuickCheck |
|---|---|---|---|---|---|
| AI 生成代码质量 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| LLM 训练数据量 | 丰富 | 非常丰富 | 中等 | 较少 | 丰富 |
| API 直觉性 | 高(链式调用) | 高(装饰器) | 中(宏语法) | 高(函数式) | 低(类型类) |
| 错误信息可读性 | 优秀 | 优秀 | 良好 | 良好 | 一般 |
| 文档完整度 | 优秀 | 优秀 | 良好 | 一般 | 优秀 |
💡 AI 友好度指 LLM 生成该框架测试代码的准确率。fast-check 和 Hypothesis 因为训练数据丰富、API 设计直觉,AI 生成的代码质量最高。
5. 从 Kiro Spec 发现属性的完整工作流
5.1 Kiro Spec 驱动的 PBT 工作流
Kiro 的 Spec 驱动开发流程天然适合 PBT:
requirements.md design.md tasks.md
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ 验收标准 │ │ Correctness │ │ PBT 任务 │
│ (AC) │───▶│ Properties │───▶│ (带属性编号) │
│ │ │ (正确性属性) │ │ │
│ WHEN X │ │ P1: ∀x: f(x)≥0 │ │ 实现 P1 测试 │
│ THEN Y │ │ P2: roundtrip │ │ 实现 P2 测试 │
└──────────────┘ └──────────────────┘ └──────────────┘5.2 实战示例:从 Spec 到 PBT
假设我们有一个文件同步工具的 Spec:
requirements.md(节选):
### Requirement 3: 文件差异计算
#### Acceptance Criteria
3.1 WHEN 两个相同的文件被比较, THEN 差异结果为空
3.2 WHEN 对文件 A 应用差异补丁, THEN 结果等于文件 B
3.3 WHEN 差异计算完成, THEN 补丁大小不超过两个文件大小之和design.md(节选):
## Correctness Properties
P1: Roundtrip — 对任意文件对 (a, b),apply(a, diff(a, b)) == b
P2: Empty diff — 对任意文件 a,diff(a, a) == empty
P3: Patch size bound — 对任意文件对 (a, b),
size(diff(a, b)) <= size(a) + size(b)AI 生成的 PBT 代码(fast-check):
import fc from 'fast-check';
import { diff, apply, DiffResult } from '../src/diff';
// 文件内容生成器:模拟真实文件的字节序列
const fileContentArb = fc.oneof(
fc.uint8Array({ minLength: 0, maxLength: 1000 }), // 小文件
fc.uint8Array({ minLength: 1000, maxLength: 10000 }), // 中等文件
fc.constant(new Uint8Array(0)), // 空文件(边界值)
);
describe('文件差异计算 - Property-Based Tests', () => {
/**
* P1: Roundtrip 属性
* Validates: Requirements 3.2
* 对任意文件对 (a, b),apply(a, diff(a, b)) == b
*/
test('P1: apply(a, diff(a, b)) should equal b', () => {
fc.assert(
fc.property(
fileContentArb,
fileContentArb,
(fileA, fileB) => {
const patch = diff(fileA, fileB);
const result = apply(fileA, patch);
expect(result).toEqual(fileB);
}
),
{ numRuns: 500 }
);
});
/**
* P2: Empty diff 属性
* Validates: Requirements 3.1
* 对任意文件 a,diff(a, a) 应为空
*/
test('P2: diff(a, a) should be empty', () => {
fc.assert(
fc.property(fileContentArb, (file) => {
const result = diff(file, file);
expect(result.changes).toHaveLength(0);
}),
{ numRuns: 500 }
);
});
/**
* P3: Patch size bound 属性
* Validates: Requirements 3.3
* 补丁大小不超过两个文件大小之和
*/
test('P3: patch size should not exceed sum of file sizes', () => {
fc.assert(
fc.property(
fileContentArb,
fileContentArb,
(fileA, fileB) => {
const patch = diff(fileA, fileB);
const patchSize = JSON.stringify(patch).length;
const maxSize = fileA.length + fileB.length;
return patchSize <= maxSize;
}
),
{ numRuns: 500 }
);
});
});5.3 需求可追溯性
每个 PBT 测试都应通过注释链接回需求文档:
requirements.md AC 3.2
│
▼
design.md Property P1
│
▼
tasks.md "实现 P1 测试"
│
▼
diff.property.test.ts
test('P1: roundtrip')
// Validates: Requirements 3.2这种可追溯性确保:
- 每个需求都有对应的属性测试
- 需求变更时能快速定位需要更新的测试
- 代码审查时能验证测试覆盖了所有需求
6. 收缩优化(Shrinking)
6.1 收缩的工作原理
当 PBT 发现一个失败的输入时,收缩器(Shrinker)会尝试将其简化为最小的仍然失败的输入。这是 PBT 最强大的特性之一——它把”在输入 [847, -23, 0, 512, -1, 99, 0, 3, -456, 72] 时失败”简化为”在输入 [-1, 0] 时失败”。
发现失败输入: [847, -23, 0, 512, -1, 99, 0, 3, -456, 72]
│
收缩过程
│
第 1 轮: [847, -23, 0, 512, -1] ← 尝试取前半部分
仍然失败 ✓ 继续收缩
│
第 2 轮: [-23, 0, -1] ← 移除不影响失败的元素
仍然失败 ✓ 继续收缩
│
第 3 轮: [-1, 0] ← 进一步简化
仍然失败 ✓ 继续收缩
│
第 4 轮: [-1] ← 尝试单元素
通过了 ✗ 回退到上一步
│
第 5 轮: [0] ← 尝试另一个单元素
通过了 ✗ 回退
│
最小反例: [-1, 0] ← 无法进一步收缩6.2 两种收缩策略
| 策略 | 代表框架 | 原理 | 优点 | 缺点 |
|---|---|---|---|---|
| 类型级收缩 | QuickCheck (Haskell) | 为每个类型定义 shrink 函数 | 收缩速度快 | 需要手动实现,组合困难 |
| 集成收缩 | Hypothesis, fast-check, proptest | 收缩与生成器绑定,自动推导 | 零配置,组合自然 | 收缩路径可能不是最优 |
6.3 收缩优化技巧
技巧 1:优先使用 map 而非 filter
// ❌ 差:filter 会导致收缩效率低下
// 因为收缩后的值可能不满足 filter 条件,被丢弃
const evenNumber = fc.integer().filter(n => n % 2 === 0);
// ✅ 好:map 保证收缩后的值仍然有效
const evenNumber = fc.integer().map(n => n * 2);原因:filter 在收缩时会丢弃不满足条件的候选值,导致收缩过程变慢甚至卡住。map 则保证任何收缩后的值经过变换后仍然有效。
技巧 2:使用 chain 处理依赖约束
// ❌ 差:生成后过滤,收缩效率低
const dateRange = fc
.tuple(fc.date(), fc.date())
.filter(([a, b]) => a < b);
// ✅ 好:用 chain 确保约束在生成时就满足
const dateRange = fc.date().chain(start =>
fc.date({ min: start }).map(end => ({ start, end }))
);技巧 3:为复杂类型提供自定义收缩提示
Hypothesis 示例:
from hypothesis import given, settings, HealthCheck
from hypothesis import strategies as st
# 使用 @example 提供已知的重要边界值
# 这些值会在随机测试之前先被测试
@given(st.lists(st.integers()))
@settings(
max_examples=1000,
suppress_health_check=[HealthCheck.too_slow],
database=None, # 禁用数据库缓存以获得更好的收缩
)
def test_sort_preserves_length(xs):
assert len(sorted(xs)) == len(xs)技巧 4:proptest 中的自定义策略收缩
use proptest::prelude::*;
// 自定义策略:生成有效的 HTTP 状态码
fn http_status_code() -> impl Strategy<Value = u16> {
prop_oneof![
// 常见状态码(高权重,更容易收缩到这些值)
3 => Just(200u16),
3 => Just(201u16),
3 => Just(400u16),
3 => Just(404u16),
3 => Just(500u16),
// 随机有效状态码(低权重)
1 => (100u16..600u16),
]
}
proptest! {
#[test]
fn test_status_code_category(code in http_status_code()) {
let category = categorize_status(code);
match code {
100..=199 => prop_assert_eq!(category, "informational"),
200..=299 => prop_assert_eq!(category, "success"),
300..=399 => prop_assert_eq!(category, "redirect"),
400..=499 => prop_assert_eq!(category, "client_error"),
500..=599 => prop_assert_eq!(category, "server_error"),
_ => prop_assert!(false, "unexpected status code: {}", code),
}
}
}6.4 收缩调试技巧
当收缩结果不理想时,可以使用以下调试方法:
fast-check:
fc.assert(
fc.property(myArbitrary, (input) => {
// 你的属性
return someCondition(input);
}),
{
numRuns: 1000,
verbose: 2, // 显示收缩过程的详细日志
endOnFailure: true, // 第一次失败就停止
// seed: 12345, // 固定种子以复现特定失败
}
);Hypothesis:
# 在 pytest 中查看收缩过程
# 运行: pytest --hypothesis-show-statistics -v
@settings(
max_examples=500,
verbosity=Verbosity.verbose, # 显示每次尝试
)
@given(st.lists(st.integers()))
def test_my_property(xs):
assert my_function(xs) >= 07. AI 生成 PBT 的完整工作流
7.1 端到端工作流
┌─────────────────────────────────────────────────────────────┐
│ AI 辅助 PBT 工作流 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 步骤 1: 属性发现 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Spec 文档 │───▶│ AI 分析 │───▶│ 属性列表 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ 步骤 2: 生成器设计 │ │
│ ┌──────────┐ ┌──────────┐ ┌────▼─────┐ │
│ │ 类型定义 │───▶│ AI 设计 │───▶│ 生成器 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ 步骤 3: 测试代码生成 │ │
│ ┌──────────┐ ┌──────────┐ ┌────▼─────┐ │
│ │ 属性+生成器│───▶│ AI 编写 │───▶│ 测试文件 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ 步骤 4: 执行与分析 │ │
│ ┌──────────┐ ┌──────────┐ ┌────▼─────┐ │
│ │ 运行测试 │───▶│ AI 分析 │───▶│ 修复建议 │ │
│ │ │ │ 反例 │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘操作步骤
步骤 1:准备 Spec 文档
确保你的项目有结构化的需求和设计文档。在 Kiro 中:
.kiro/specs/my-feature/
├── requirements.md # 包含验收标准
├── design.md # 包含 Correctness Properties
└── tasks.md # 包含 PBT 任务步骤 2:让 AI 发现属性
使用以下 Prompt 让 AI 从 Spec 中提取属性:
请阅读以下 requirements.md 和 design.md,识别所有可以用
Property-Based Testing 验证的属性。
对每个属性,请提供:
1. 属性名称(如 P1, P2...)
2. 自然语言描述
3. 形式化表达(使用 ∀ 量词)
4. 对应的验收标准编号
5. 推荐的测试框架
[粘贴 Spec 内容]步骤 3:让 AI 设计生成器
基于以下属性列表和数据类型定义,为每个属性设计智能生成器。
## 属性列表
[步骤 2 的输出]
## 数据类型
[粘贴相关类型定义]
## 要求
- 使用 [框架名] 语法
- 生成器应覆盖边界值
- 优先使用 map/chain 而非 filter
- 为复杂类型提供分层生成策略步骤 4:让 AI 生成完整测试代码
请基于以下属性和生成器,生成完整的 PBT 测试文件。
## 属性和生成器
[步骤 2 和 3 的输出]
## 要求
- 每个测试用 JSDoc/docstring 注释说明属性
- 包含 "Validates: Requirements X.Y" 可追溯性标注
- 设置合理的 numRuns(默认 500)
- 包含 seed 固定机制以便复现步骤 5:运行测试并分析反例
# fast-check (Jest/Vitest)
npx vitest run --reporter=verbose src/**/*.property.test.ts
# Hypothesis (pytest)
pytest --hypothesis-show-statistics -v tests/property/
# proptest (Rust)
cargo test --lib -- --nocapture property_tests
# rapid (Go)
go test -v -run TestProperty ./...步骤 6:AI 分析反例
当测试失败时,将反例交给 AI 分析:
PBT 测试失败,请分析以下反例并判断原因:
## 失败的属性
[属性描述]
## 反例
[框架输出的最小反例]
## 相关代码
[被测函数的实现]
## 请判断
1. 这是测试本身的问题(属性定义不正确)?
2. 这是代码的 bug(需要修复实现)?
3. 这是 Spec 的问题(需求不完整或矛盾)?
请给出你的判断理由和修复建议。8. 反例分析与调试
8.1 反例分类决策树
当 PBT 发现反例时,需要判断问题出在哪里。以下是系统化的分类方法:
PBT 测试失败,得到反例 X
│
├── 反例 X 是否满足属性的前置条件?
│ ├── 否 → 生成器问题:生成器产生了无效输入
│ │ 修复:调整生成器约束
│ │
│ └── 是 → 继续判断
│ │
│ ├── 手动验证:f(X) 的结果是否符合预期?
│ │ ├── 不符合 → 代码 bug
│ │ │ 修复:修改被测函数实现
│ │ │
│ │ └── 符合 → 继续判断
│ │ │
│ │ ├── 属性定义是否正确反映了需求?
│ │ │ ├── 否 → 属性定义错误
│ │ │ │ 修复:调整属性定义
│ │ │ │
│ │ │ └── 是 → 需求本身可能有问题
│ │ │ 行动:与产品经理/用户确认需求
│ │ │
│ │ └── 是否是浮点精度问题?
│ │ └── 是 → 使用近似比较
│ │ 修复:expect(a).toBeCloseTo(b)
│ │
│ └── 反例是否涉及并发/时序?
│ └── 是 → 可能是竞态条件
│ 修复:添加同步机制8.2 常见反例类型及处理
| 反例类型 | 典型表现 | 根因 | 处理方式 |
|---|---|---|---|
| 空输入 | [], "", 0 | 未处理空值边界 | 修复代码:添加空值检查 |
| 极大值 | Number.MAX_SAFE_INTEGER | 整数溢出 | 修复代码:添加范围检查 |
| Unicode | "🎉", "\u0000" | 字符编码处理不当 | 修复代码:正确处理 Unicode |
| 负数 | -1, -Infinity | 未考虑负数输入 | 修复代码或调整生成器 |
| 重复元素 | [1, 1, 1] | 去重逻辑错误 | 修复代码:检查去重实现 |
| 精度丢失 | 0.1 + 0.2 ≠ 0.3 | 浮点精度 | 使用 epsilon 比较 |
| 顺序敏感 | 不同排列产生不同结果 | 算法不稳定 | 修复代码或明确排序规则 |
8.3 AI 辅助反例分析的 Prompt 模板
你是一位 PBT 反例分析专家。请分析以下测试失败:
## 测试属性
名称: [属性名]
描述: [自然语言描述]
形式化: [∀ x: ...]
## 反例(框架收缩后的最小反例)
输入: [反例值]
期望: [期望的输出/行为]
实际: [实际的输出/行为]
## 被测代码
```[语言]
[被测函数实现]请分析
- 分类: 这是 (a) 代码 bug, (b) 测试/属性定义错误, 还是 (c) 需求/Spec 问题?
- 根因: 为什么这个输入导致了失败?
- 修复方案:
- 如果是代码 bug,给出修复代码
- 如果是属性错误,给出修正后的属性
- 如果是 Spec 问题,说明需要澄清的点
- 回归防护: 建议添加什么额外的测试来防止回归?
### 8.4 反例复现与回归测试
发现的反例应该被固化为回归测试:
**fast-check**:
```typescript
// 将 PBT 发现的反例固化为具体测试
describe('回归测试(来自 PBT 反例)', () => {
// 2025-06-15 PBT 发现:空数组导致除零错误
test('regression: empty array should return 0 average', () => {
expect(average([])).toBe(0);
});
// 2025-06-16 PBT 发现:负数价格导致总价计算错误
test('regression: negative price should be rejected', () => {
expect(() => createOrder([{ price: -1, qty: 1 }])).toThrow();
});
});
// 也可以使用 fast-check 的 seed 复现
fc.assert(
fc.property(myArbitrary, myProperty),
{ seed: 1234567890 } // 使用失败时记录的 seed
);Hypothesis:
from hypothesis import given, example
# Hypothesis 自动将发现的反例存入数据库
# 下次运行时会优先测试这些已知的失败输入
# 也可以手动添加 @example 装饰器
@given(st.lists(st.integers()))
@example([]) # 空列表(PBT 发现的边界值)
@example([0, 0, 0]) # 全零列表(PBT 发现的边界值)
@example([-1, 1]) # 正负混合(PBT 发现的边界值)
def test_sort_preserves_elements(xs):
sorted_xs = sorted(xs)
assert sorted(sorted_xs) == sorted(xs)9. 属性模式目录
9.1 完整属性模式参考
以下是 AI 在发现属性时应参考的完整模式目录,每种模式附带多语言示例:
模式 1:往返(Roundtrip)
适用场景:序列化/反序列化、编码/解码、压缩/解压
// fast-check: JSON 序列化往返
fc.assert(
fc.property(fc.anything(), (value) => {
const serialized = JSON.stringify(value);
const deserialized = JSON.parse(serialized);
// 注意:JSON 不支持 undefined、函数等,需要约束输入
expect(deserialized).toEqual(value);
})
);# Hypothesis: Base64 编码往返
@given(st.binary())
def test_base64_roundtrip(data: bytes):
encoded = base64.b64encode(data)
decoded = base64.b64decode(encoded)
assert decoded == data// Go rapid: URL 编码往返
func TestURLEncodingRoundtrip(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
original := rapid.String().Draw(t, "input")
encoded := url.QueryEscape(original)
decoded, err := url.QueryUnescape(encoded)
if err != nil {
t.Fatal(err)
}
if decoded != original {
t.Fatalf("roundtrip failed: %q -> %q -> %q",
original, encoded, decoded)
}
})
}模式 2:幂等(Idempotent)
适用场景:排序、去重、格式化、规范化、HTTP PUT/DELETE
// fast-check: 排序幂等
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted1 = [...arr].sort((a, b) => a - b);
const sorted2 = [...sorted1].sort((a, b) => a - b);
expect(sorted2).toEqual(sorted1);
})
);# Hypothesis: HTML 清理幂等
@given(st.text())
def test_sanitize_idempotent(html: str):
once = sanitize_html(html)
twice = sanitize_html(once)
assert once == twice模式 3:不变量(Invariant)
适用场景:集合操作、数值计算、状态转换
// fast-check: filter 后长度不变量
fc.assert(
fc.property(
fc.array(fc.integer()),
fc.func(fc.boolean()), // 随机谓词
(arr, predicate) => {
const filtered = arr.filter(predicate);
return filtered.length <= arr.length;
}
)
);// Go rapid: Map 操作后键集合不变量
func TestMapKeysInvariant(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
m := rapid.MapOf(
rapid.String(),
rapid.Int(),
).Draw(t, "map")
// 对所有值加 1
transformed := make(map[string]int)
for k, v := range m {
transformed[k] = v + 1
}
// 键集合应该不变
if len(transformed) != len(m) {
t.Fatal("key set changed after value transformation")
}
for k := range m {
if _, ok := transformed[k]; !ok {
t.Fatalf("key %q missing after transformation", k)
}
}
})
}模式 4:等价/Oracle
适用场景:优化实现 vs 参考实现、新旧版本对比
# Hypothesis: 快速排序 vs 内置排序
@given(st.lists(st.integers()))
def test_quicksort_matches_builtin(xs):
assert quicksort(xs) == sorted(xs)模式 5:变形(Metamorphic)
适用场景:搜索引擎、机器学习模型、数值计算
// fast-check: 搜索结果的变形属性
// 添加更多搜索关键词不应增加结果数量
fc.assert(
fc.property(
fc.string({ minLength: 1 }), // 基础查询
fc.string({ minLength: 1 }), // 额外关键词
(query, extraKeyword) => {
const results1 = search(query);
const results2 = search(`${query} ${extraKeyword}`);
// 更精确的查询结果应该 ≤ 更宽泛的查询
return results2.length <= results1.length;
}
)
);模式 6:保持属性(Preservation)
适用场景:排序保持元素集合、转换保持信息量
# Hypothesis: 排序保持元素集合
@given(st.lists(st.integers()))
def test_sort_preserves_elements(xs):
sorted_xs = sorted(xs)
assert sorted(sorted_xs) == sorted(xs) # 多重集相等
assert len(sorted_xs) == len(xs)模式 7:交换律/结合律(Algebraic)
适用场景:数学运算、集合操作、字符串拼接
// fast-check: 集合并集的交换律
fc.assert(
fc.property(
fc.uniqueArray(fc.integer()),
fc.uniqueArray(fc.integer()),
(setA, setB) => {
const unionAB = new Set([...setA, ...setB]);
const unionBA = new Set([...setB, ...setA]);
expect(unionAB).toEqual(unionBA);
}
)
);10. 各框架的 Prompt 模板
10.1 fast-check (JavaScript/TypeScript)
请使用 fast-check 为以下 TypeScript 函数编写 Property-Based Tests。
## 函数签名
```typescript
[粘贴函数签名和 JSDoc]要求
- 使用 fc.property() 和 fc.assert() API
- 为每个属性创建独立的 test() 块
- 使用 fc.record() 生成复杂对象
- 设置 numRuns: 500
- 添加 “Validates: Requirements X.Y” 注释
- 优先使用 map/chain 而非 filter 构建生成器
- 包含边界值:空数组、空字符串、0、负数
- 测试文件命名为 *.property.test.ts
属性提示
请检查以下属性模式是否适用:
- 往返(如果存在逆操作)
- 幂等(如果是规范化/格式化操作)
- 不变量(输出的范围/大小约束)
- 保持(操作前后元素集合是否不变)
### 10.2 Hypothesis (Python)
请使用 Hypothesis 为以下 Python 函数编写 Property-Based Tests。
函数签名
[粘贴函数签名和 docstring]要求
- 使用 @given 装饰器和 hypothesis.strategies
- 使用 @settings(max_examples=500) 设置运行次数
- 使用 @example() 添加已知的重要边界值
- 对复杂类型使用 @composite 策略
- 如果涉及状态,使用 RuleBasedStateMachine
- 添加 “Validates: Requirements X.Y” docstring
- 测试文件命名为 test_*_properties.py
- 与 pytest 集成,使用 pytest-hypothesis 插件
属性提示
请检查以下属性模式是否适用:
- 往返(serialize/deserialize 对)
- 幂等(重复操作等价)
- 不变量(输出约束)
- 状态机(如果涉及有状态操作)
### 10.3 Go rapid
请使用 pgregory.net/rapid 为以下 Go 函数编写 Property-Based Tests。
函数签名
[粘贴函数签名和注释]要求
- 使用 rapid.Check(t, func(t *rapid.T) {…}) 模式
- 使用 rapid.Custom() 构建自定义生成器
- 使用 t.Fatal/t.Fatalf 报告失败
- 生成器使用 .Draw(t, “label”) 获取值
- 测试函数命名为 TestProperty_*
- 添加 “Validates: Requirements X.Y” 注释
- 测试文件命名为 *_property_test.go
属性提示
请检查以下属性模式是否适用:
- 往返(encoding/decoding)
- 幂等(sort/normalize)
- 不变量(size/range constraints)
- 等价(optimized vs reference implementation)
### 10.4 proptest (Rust)
请使用 proptest 为以下 Rust 函数编写 Property-Based Tests。
函数签名
[粘贴函数签名和文档注释]要求
- 使用 proptest! 宏定义测试
- 使用 prop::strategy 组合策略
- 使用 prop_assert! 和 prop_assert_eq! 断言
- 对复杂类型使用 prop_compose! 宏
- 使用 prop_oneof! 生成枚举变体
- 添加 “Validates: Requirements X.Y” 注释
- 在 #[cfg(test)] 模块中编写
- 使用 ProptestConfig 配置运行次数
属性提示
请检查以下属性模式是否适用:
- 往返(serde 序列化)
- 幂等(normalize/canonicalize)
- 不变量(类型约束、范围检查)
- 代数属性(交换律、结合律)
---
## 实战案例:电商订单系统的 AI 辅助 PBT
### 案例背景
一个电商平台的订单计算模块,需要验证价格计算、折扣应用、库存扣减等核心逻辑。团队使用 Kiro 的 Spec 驱动工作流,从需求文档中提取属性并生成 PBT。
### 案例分析
#### 第 1 步:从需求提取属性
**requirements.md(节选)**:
```markdown
### Requirement: 订单价格计算
#### Acceptance Criteria
AC1: WHEN 订单包含多个商品, THEN 总价等于所有商品(单价×数量)之和
AC2: WHEN 应用折扣码, THEN 折扣后价格 = 原价 × (1 - 折扣率)
AC3: WHEN 折扣后价格计算完成, THEN 最终价格不低于 0
AC4: WHEN 订单创建成功, THEN 库存应减少对应数量
AC5: WHEN 订单取消, THEN 库存应恢复到订单创建前的状态AI 分析后提取的属性:
| AC | 属性类型 | 形式化描述 |
|---|---|---|
| AC1 | 不变量 | ∀ order: total(order) == Σ(item.price × item.qty) |
| AC2 | 不变量 | ∀ order, discount: discounted(order, d) == total(order) × (1 - d) |
| AC3 | 边界 | ∀ order, discount: finalPrice(order, d) >= 0 |
| AC4 | 不变量 | ∀ order: stock_after == stock_before - Σ(item.qty) |
| AC5 | 往返 | ∀ order: cancel(create(order)).stock == original_stock |
第 2 步:设计生成器
import fc from 'fast-check';
// 商品生成器
const productArb = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 50 }),
price: fc.integer({ min: 1, max: 10_000_00 }), // 1分 ~ 10万元(分为单位)
stock: fc.integer({ min: 0, max: 10000 }),
});
// 订单项生成器(数量不超过库存)
const orderItemArb = (maxStock: number) =>
fc.record({
productId: fc.uuid(),
price: fc.integer({ min: 1, max: 10_000_00 }),
quantity: fc.integer({ min: 1, max: Math.max(1, maxStock) }),
});
// 订单生成器
const orderArb = fc.array(
orderItemArb(100),
{ minLength: 1, maxLength: 20 }
);
// 折扣率生成器(0% ~ 100%)
const discountArb = fc.integer({ min: 0, max: 100 });第 3 步:编写属性测试
describe('订单价格计算 - Property-Based Tests', () => {
/**
* AC1: 总价等于所有商品(单价×数量)之和
* Validates: Requirements AC1
*/
test('P1: total equals sum of item prices × quantities', () => {
fc.assert(
fc.property(orderArb, (items) => {
const expectedTotal = items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
const actualTotal = calculateTotal(items);
return actualTotal === expectedTotal;
}),
{ numRuns: 1000 }
);
});
/**
* AC2: 折扣后价格 = 原价 × (1 - 折扣率)
* Validates: Requirements AC2
*/
test('P2: discounted price equals total × (1 - rate)', () => {
fc.assert(
fc.property(orderArb, discountArb, (items, discountPercent) => {
const total = calculateTotal(items);
const discounted = applyDiscount(total, discountPercent);
const expected = Math.floor(total * (100 - discountPercent) / 100);
return discounted === expected;
}),
{ numRuns: 1000 }
);
});
/**
* AC3: 最终价格不低于 0
* Validates: Requirements AC3
*/
test('P3: final price is never negative', () => {
fc.assert(
fc.property(orderArb, discountArb, (items, discountPercent) => {
const total = calculateTotal(items);
const finalPrice = applyDiscount(total, discountPercent);
return finalPrice >= 0;
}),
{ numRuns: 1000 }
);
});
/**
* AC5: 订单取消后库存恢复(往返属性)
* Validates: Requirements AC5
*/
test('P5: cancel(create(order)) restores stock', () => {
fc.assert(
fc.property(orderArb, (items) => {
const initialStock = new Map(
items.map(item => [item.productId, 1000])
);
// 创建订单 → 扣减库存
const afterCreate = createOrder(items, new Map(initialStock));
// 取消订单 → 恢复库存
const afterCancel = cancelOrder(afterCreate.orderId, afterCreate.stock);
// 库存应该恢复到初始状态
for (const [id, qty] of initialStock) {
if (afterCancel.get(id) !== qty) return false;
}
return true;
}),
{ numRuns: 500 }
);
});
});第 4 步:PBT 发现的 Bug
运行测试后,PBT 发现了以下反例:
反例 1:当折扣率为 100% 时,applyDiscount 返回 -0 而非 0
- 分类:代码 bug
- 修复:
return Math.max(0, Math.floor(total * (100 - rate) / 100))
反例 2:当订单包含同一商品的多个条目时,库存扣减重复计算
- 分类:代码 bug
- 修复:合并同一商品的数量后再扣减
反例 3:当商品价格为 1 分且数量为 1 时,折扣后价格向下取整为 0
- 分类:Spec 问题——需要确认最低价格策略
- 行动:与产品经理确认是否允许 0 元订单
案例总结
| 指标 | 传统单元测试 | AI 辅助 PBT |
|---|---|---|
| 编写时间 | 2 小时(手动编写 20+ 测试用例) | 30 分钟(AI 生成属性和生成器) |
| 发现的 bug | 0(手动测试未覆盖边界值) | 3(包括 1 个 Spec 问题) |
| 测试覆盖的输入空间 | 20 个具体值 | 5000+ 随机值 |
| 可维护性 | 需求变更时需逐个修改测试 | 属性不依赖具体值,更稳定 |
避坑指南
❌ 常见错误
-
过度使用 filter 构建生成器
- 问题:
fc.integer().filter(n => isPrime(n))会导致大量生成的值被丢弃,测试变慢,收缩效率极低 - 正确做法:使用
fc.integer({min: 2, max: 1000}).map(n => nthPrime(n))或预计算素数列表后用fc.constantFrom(...primes)
- 问题:
-
属性定义过于宽泛
- 问题:
∀ x: f(x) != null这样的属性几乎不能发现任何 bug - 正确做法:定义精确的属性,如
∀ x: f(x).length == x.length或∀ x: f(f(x)) == f(x)
- 问题:
-
忽略收缩结果直接看原始反例
- 问题:原始反例可能包含大量无关信息,难以定位问题
- 正确做法:始终关注框架收缩后的最小反例,它通常直接指向 bug 的根因
-
在属性测试中使用 mock
- 问题:mock 会掩盖真实的交互问题,PBT 的价值在于测试真实行为
- 正确做法:PBT 应该测试纯函数或使用真实的(内存中的)依赖
-
numRuns 设置过低
- 问题:默认的 100 次运行可能不足以发现低概率的边界 bug
- 正确做法:对关键业务逻辑设置 500-1000 次,对简单属性 100-200 次即可
-
不固化 PBT 发现的反例
- 问题:PBT 发现的 bug 修复后,如果不添加回归测试,可能再次引入
- 正确做法:将每个反例转化为具体的单元测试(
@example或独立 test case)
-
让 AI 同时写代码和测试
- 问题:AI 可能在代码和测试中犯同样的理解错误,导致”测试通过但逻辑错误”
- 正确做法:先让 AI 从 Spec 生成属性测试,再让 AI(或人工)实现代码,保持测试和实现的独立性
-
忽略浮点精度问题
- 问题:
0.1 + 0.2 !== 0.3导致大量虚假失败 - 正确做法:使用整数运算(如分为单位的金额)或 epsilon 比较
- 问题:
-
生成器不覆盖边界值
- 问题:纯随机生成可能永远不会生成
[]、""、0等边界值 - 正确做法:使用
fc.oneof(fc.constant([]), fc.array(...))显式包含边界值
- 问题:纯随机生成可能永远不会生成
-
将 PBT 当作 fuzzing 使用
- 问题:PBT 的目标是验证属性,不是发现崩溃。如果只检查”不抛异常”,那是 fuzzing 而非 PBT
- 正确做法:每个 PBT 测试都应该有明确的属性断言,而非仅仅”不崩溃”
✅ 最佳实践
-
从 Spec 开始,而非从代码开始:先定义属性(“代码应该做什么”),再实现代码。这是 TDD 在 PBT 领域的自然延伸。
-
属性命名使用 P1, P2… 编号:便于在 Spec、测试代码和 CI 报告之间建立可追溯性。
-
每个属性测试只验证一个属性:不要在一个 test 中检查多个属性,这样失败时更容易定位问题。
-
生成器是可复用的资产:为项目的核心数据类型建立生成器库(如
generators/order.ts),在多个属性测试中复用。 -
PBT + 单元测试互补使用:PBT 验证通用属性,单元测试验证具体的业务规则和边界值。两者不是替代关系。
-
CI 中设置合理的超时:PBT 运行时间不确定,在 CI 中设置 5-10 分钟的超时,避免收缩过程无限运行。
-
定期增加 numRuns:在 nightly build 中使用更高的 numRuns(如 10000),在 PR check 中使用较低的值(如 200)以平衡速度和覆盖度。
-
记录 seed 以便复现:所有 PBT 框架都支持 seed 固定。在 CI 日志中记录 seed,以便复现失败。
相关资源与延伸阅读
- fast-check 官方文档 — JavaScript/TypeScript PBT 框架的完整 API 文档和教程,包含丰富的生成器示例和最佳实践
- Hypothesis 官方文档 — Python PBT 框架的权威指南,涵盖策略组合、状态机测试和数据库集成
- proptest Book — Rust proptest 框架的在线书籍,详细讲解策略设计和收缩机制
- rapid GitHub 仓库 — Go 现代 PBT 库,README 包含完整的使用指南和示例
- Property-Based Testing with PropEr, Erlang, and Elixir — Fred Hebert 的经典 PBT 书籍,虽然以 Erlang/Elixir 为例,但属性设计思想通用
- jqwik 用户指南 — JVM 生态 PBT 框架,与 JUnit 5 深度集成
- Kiro Spec 驱动开发文档 — 了解如何在 Kiro 中使用 requirements.md 和 design.md 驱动 PBT
- QuickCheck 论文(原始论文) — Claessen & Hughes 2000 年的开创性论文,理解 PBT 的理论基础
- Choosing properties for property-based testing — Scott Wlaschin 的经典博文,系统化讲解如何选择属性模式
- pbt Rust crate — 2025 年发布的 Rust 新一代 PBT 库,在收缩能力上优于 proptest 和 quickcheck
参考来源
- fast-check 官方文档 (持续更新)
- Hypothesis 官方文档 (持续更新)
- proptest-rs GitHub (持续更新,MSRV 1.82)
- rapid GitHub (持续更新)
- pbt Rust crate (2025 年发布)
- QuickCheck - Wikipedia (2025 年更新)
- Comprehensive Guide to Property-Based Testing in Go - DZone (2025 年 5 月)
- Let Hypothesis Break Your Python Code - Towards Data Science (2025 年 6 月)
- pytest, Hypothesis, and Contract Testing (2025 年 12 月)
- Proptest Rust Guide 2025 (2025 年)
- Test-First Prompting: Using TDD for Secure AI-Generated Code (2025 年 12 月)
- System Test Case Design from Requirements Specifications - arXiv (2024 年 12 月)
- Stateful Property Testing in Rust - ReadySet (2025 年 1 月)
📖 返回 总览与导航 | 上一节:31c-需求到测试用例自动化 | 下一节:31e-测试Steering规则与反模式