用例子把规则谈清楚:实例化需求、反例与可执行规格
需求文档写着:「VIP 用户享受免运费」。开发实现了,测试也通过了。上线第二天,客服收到投诉:一个 VIP 用户买了一台冰箱,运费被免了,物流成本是商品利润的两倍。产品经理说:「大件当然不包括在内,这还用说吗?」
用自然语言写的规则,读起来总是清楚的,问题藏在每个人默认补上的那部分里。「VIP 免运费」对产品经理意味着「普通包裹」,对开发意味着「所有订单」,对财务可能意味着「每月前三单」。抽象的讨论很难暴露这些差异,因为大家都同意那句话本身。
一个更有效的办法是把规则落到具体的例子上:给出输入,让每个人说出期望的结果。分歧会立刻出现在某一个例子上。本文介绍怎样用例子发现规则、怎样写反例、怎样让例子成为可执行的规格并随需求一起维护。这是一篇方法文,文中的实测引用自站内其他文章。
一、从一句规则到一张例子表
拿着「VIP 免运费」去找业务方,不要问「这条规则对吗」,而是摆出几个具体的订单:
| 用户 | 商品 | 重量 | 收货地 | 期望运费 | 讨论中补上的规则 |
|---|---|---|---|---|---|
| VIP | 书 | 1 公斤 | 上海 | 0 | — |
| 普通 | 书 | 1 公斤 | 上海 | 10 | — |
| VIP | 冰箱 | 80 公斤 | 上海 | ? | 大件(超过 30 公斤)不免运费 |
| VIP | 书 | 1 公斤 | 新疆 | ? | 偏远地区只免基础运费,加收部分照收 |
| VIP(今天刚过期) | 书 | 1 公斤 | 上海 | ? | 按下单时的身份判断 |
前两行大家都同意,后三行每一行都引出了一条之前没有写下来的规则。这就是例子的作用:它迫使人对一个具体的情况表态,而表态的分歧正好标出了规则的边界。
选择例子有几个来源:
- 边界:数值规则的临界点(30 公斤、29.9 公斤)、时间规则的切换时刻(会员过期的那一天)。
- 组合:两条规则同时适用时谁优先(VIP 且大件、VIP 且偏远)。
- 状态:同一个操作在不同状态下(已发货的订单还能不能改地址)。
- 历史上出过问题的情况:客服工单、线上事故里的真实案例,去掉个人信息后作为例子。
二、反例:规则不适用的地方同样是规则
只写「应该发生什么」的例子,很容易漏掉「不应该发生什么」。每条规则都要配上反例:
- 正例:VIP 买书,运费 0。
- 反例:VIP 买冰箱,运费不为 0。
- 反例:普通用户买书,运费不为 0(防止「免运费」被实现成对所有人生效)。
反例往往比正例更能暴露实现中的错误。一个永远返回 0 的运费函数能通过所有 VIP 的正例;一个忽略重量的实现能通过所有小件的例子。反例和覆盖率与变异测试里讲的「把代码改错,测试会不会失败」是同一个思路:例子的作用是区分正确与错误的实现,只有正例的例子表,区分能力是不够的。
三、例子的格式:给定、当、那么
例子讨论清楚之后,写成统一的格式,便于业务方阅读,也便于转成测试:
规则:大件不享受 VIP 免运费
给定 用户是 VIP
并且 购物车里有一台 80 公斤的冰箱
当 用户结算
那么 运费按大件标准收取 120 元几个写法上的要求:
- 一个例子只验证一条规则。「给定」里只放与这条规则有关的条件,无关的细节(用户名、商品颜色)由测试的默认数据提供。
- 用业务语言,不用实现语言。写「用户是 VIP」,不写「
user.level = 3」。 - 期望是具体的值。「运费正确」不是期望,「运费 120 元」才是。
参数化的例子表在代码里就是一个带显示名的参数化测试,失败时能直接说出是哪一个例子错了,见从行为到场景到断言。事件风暴里产出的规则怎样变成这种格式的验收测试,见事件风暴。
四、可执行规格的维护
例子转成自动化测试之后,就是一份可执行的规格。它比文档多了一个能力:与代码不一致时会失败。但它也会腐坏,常见的几种:
- 测试失败后被改成「符合当前行为」:期望值被改掉,没有人回去问业务方规则是否真的变了。规则变更必须先改例子表、得到业务方确认,再改代码。
- 例子越积越多,没人知道哪些还有效:每个例子关联到一条规则,规则被删除时,例子一起删除。
- 步骤定义变成了另一套代码:使用 Gherkin 一类工具时,「给定、当、那么」背后的胶水代码可能比被测代码还复杂。胶水代码要薄,只做语言到 API 的翻译。
- 例子依赖共享的测试数据:一个例子修改了公共数据,另一个例子随之失败。每个例子准备自己需要的数据,见测试数据归谁。
工具不是重点。用 JUnit 的参数化测试、用 Cucumber,还是用表格驱动的自定义运行器都可以;重点是例子来自与业务方的讨论,并且规则变化时例子先变。
五、什么时候不值得
为每条规则都组织一次例子讨论,成本不低。下面这些情况,直接写测试就够了:
- 规则是纯技术性的(日志格式、重试间隔),业务方不关心。
- 规则简单到没有分歧的空间,并且出错的代价很小。
值得投入的是那些出错代价高、或者已经出过一次分歧的规则:计费、权限、资格判定、状态转换。一个经验信号是:如果评审时有人说「这还用说吗」,这条规则就值得用例子再确认一次。
六、检查清单
- 拿具体的例子让业务方表态,而不是请他们确认一句抽象的规则。
- 例子覆盖边界、组合、状态和历史问题。
- 每条规则至少有一个反例。
- 一个例子只验证一条规则,用业务语言,期望是具体的值。
- 规则变化时先改例子、经业务方确认,再改代码;测试失败不能直接改期望值。
- 每个例子关联到一条规则,规则删除时例子一起删除。
参考资料
- Gojko Adzic,《Specification by Example》
- Matt Wynne,Introducing Example Mapping
- Dan North,Introducing BDD