42 基于特性测试
用生成器探索输入空间,用不变量裁决结果,并通过缩减找到最小反例。
学习目标
- 能把业务假设写成生成器、前置约束、不变量和拒绝条件
- 能固定随机种子运行特性测试,在失败后缩减出可读、可回归的最小反例
- 能区分生成器缺陷、性质缺陷和实现缺陷,并以重放证据选择修复路径
让测试探索假设,而不是堆例子
本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 42 基于特性测试。正文、代码、图示、实验和练习都是本课程重新设计的教学材料,不复制原书正文、插图或答案。
手工样例擅长表达几个重要故事,特性测试则用生成器探索一组输入,用不变量裁决输出是否仍符合假设。它不是“随机多跑几次”,而是把输入空间、合法边界、结果关系、随机种子和失败缩减都写成可复核协议。生成器越强,性质越准确,反例才越有信息量。
三个会让特性测试失真的陷阱
从生成器到最小反例
<Term def="根据约束构造一组可复现输入,并覆盖边界、组合和分布变化的输入程序。">生成器</Term>决定测试探索什么。生成器不是越随机越好;它应说明合法域、边界权重、组合规则和种子。一个金额生成器若从不产生零、极大值或重复商品,就无法验证对应风险。
<Term def="规定输入是否合法、性质何时适用以及非法输入应如何被拒绝的前置规则。">前置约束</Term>把探索空间与业务规则连接起来。有效购物车可以要求数量非负、货币一致;域外样本则应验证拒绝原因,而不是强行代入总价守恒性质。
<Term def="对输入和输出必须持续成立的业务关系,如守恒、幂等、可交换、往返或单调关系。">不变量</Term>要能被反例推翻。对购物车可以写:合并空购物车不改变结果;同一幂等键重复结算不增加订单;按项拆分再合并的总价相等。每个性质都要说明舍入、时区和精度边界。
42 与提示 71:使用特性测试来校验假设
本单元对应中文目录的 42 基于特性测试 和 提示 71:使用基于特性的测试来校验假设。提示的实践含义是:当你相信某个关系对一类输入成立时,用生成器扩大探索,用不变量定义裁决,并让缩减结果帮助你修正假设。
type Cart = ReadonlyArray<{ sku: string; quantity: number; price: number }>;
function total(cart: Cart): number {
return cart.reduce((sum, item) => sum + item.quantity * item.price, 0);
}
function addEmpty(cart: Cart): Cart {
return [...cart];
}可以验证 total(addEmpty(cart)) === total(cart),也可以生成两组合法购物车,检查合并后总价等于分别计算之和。若金额有小数,就要先声明精度与舍入规则;否则失败可能来自性质表述,而不是实现。
缩减:让失败变得可理解
<Term def="在保持性质失败的前提下,逐步移除元素、缩小数值或简化结构,得到最小可读失败输入的过程。">缩减</Term>是特性测试的解释阶段。一个包含五十件商品、三种货币和多个时区的失败样本很难定位;如果能缩成两件商品和一个重复键,团队才能讨论真正的规则。
缩减器也需要审查。若它删除了触发故障的关系,最后的样本可能变成绿色,测试工具却报告“没有最小反例”。保存原始输入、每一步缩减和最终失败输入;若缩减失败,保留未缩减样本并明确限制,不能假装已经定位。
种子、版本与重放
<Term def="决定生成器选择顺序的可记录值;结合生成器版本和运行环境可以重建一次探索。">随机种子</Term>是重放协议的一部分。记录种子还不够:生成器代码、库版本、平台、时区、前置约束和执行次数也可能改变序列。回归测试应直接加入缩减后的最小反例,种子保留为调查来源。
一次通过也有边界。若只运行固定种子,探索会变窄;若每次使用不可记录的新种子,失败又无法重放。推荐保存一组稳定种子、定期轮换的探索种子,以及每次失败的完整生成信息。
练习实验:从性质到回归
Interactive lab
选择样本,判断失败属于哪一层
输入证据
负数量与混合货币被生成器标出,接口返回明确拒绝。
实际首差
前置节点:域外输入不进入业务性质。
恢复动作
保存约束、边界值和拒绝原因。
先写性质和拒绝规则,再选择样本;最小反例比“运行了很多次”更有复核价值。
1. 选择变换与业务关系
写出一个有意义的变换,例如加入空项、合并购物车、序列化后再解析。为它定义至少两个不变量,并明确精度、非法输入和拒绝条件。
正常、边界与单一故障证据
| 样本 | 唯一变化 | 预期判定 | 必存证据 |
|---|---|---|---|
| 正常 | 合法生成器与稳定性质 | 多样输入均满足关系 | 种子、样本摘要和性质结果 |
| 边界 | 空、零、极大或域外输入 | 接受或拒绝规则明确 | 前置约束、边界值和原因 |
| 单一故障 | 一个实现、性质或生成器条件失效 | 首个失败关系暴露 | 原始样本、缩减过程和回退 |
独立复核者应能用种子和版本重建失败,再确认最小反例仍然失败。测试通过次数不能替代性质质量;如果生成器只产生容易的输入,绿色结果的范围必须明确写出。
区分三种失败
生成器失败表现为样本不满足前置约束、分布偏斜或无法缩减;性质失败表现为关系写错、舍入规则遗漏或前提过宽;实现失败表现为合法输入确实违反一个清晰不变量。先分类再改代码,避免把错误性质“修复”成更弱的断言。
工具可以自动生成和缩减,但团队仍要评审它覆盖的输入域、忽略的副作用、随机策略和错误报告。涉及时间、并发、网络或权限时,给生成器注入可控时钟和资源边界;否则随机样本会把环境噪声伪装成业务反例。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 生成器
根据约束构造并探索一组可复现输入的程序。
- 前置约束
规定输入是否合法、性质何时适用以及域外输入如何拒绝的规则。
- 不变量
对输入和输出必须成立、可以被反例推翻的业务关系。
- 缩减
在保持失败的前提下简化样本,得到最小可读反例的过程。
- 随机种子
决定生成顺序、可与生成器版本一起重建探索的记录值。
练习
练习
问题 1: 你要测试“合并购物车后总价等于分别计算再相加”。需要哪些前置约束?
问题 2: 特性测试找到一个包含 100 条记录的失败样本,缩减后变成一条记录但仍失败。如何判断缩减是否有效?
问题 3: 固定种子连续运行 10,000 次都通过,但生产出现失败。你会如何更新证据?
本单元回顾
基于特性测试,不是把随机性当成证明,而是把输入域、前置约束、不变量、种子和缩减都变成可复核证据。好的失败会变小、能重放、能进入回归;好的通过则清楚说明生成器探索了什么、性质没有覆盖什么,以及哪些环境条件仍需额外测试。