判定表[article:973b0dc2]解决的是“这一刻几个条件叠在一起算什么”。但订单还有另一个维度,判定表写不下:同样的操作,之前发生过什么不同,结果就不同。
用户点“取消订单”,在待支付时是正常操作,在已支付时必须拒绝。“商家同意退款”这个事件,在退款中是把钱退回去,在已完成时根本不该存在。这类“反应取决于当前状态”的功能,要用状态迁移来测。
先给出题目
案例是编的,需求四条:
- 下单后进入 待支付,30 分钟内未付款自动取消,用户也可主动取消。
- 付款成功进入 已支付,商家发货进入 已发货。
- 已支付时可申请退款,进入 退款中;商家同意到 已退款,拒绝则回到 已支付。
- 已发货后,用户确认收货或发货满 15 天自动确认,进入 已完成。
请先自己列一遍用例,再往下看。
第一步:拆出状态、事件、动作、迁移
- 状态:系统停在哪等事件。这里是待支付、已支付、已发货、已完成、已取消、退款中、已退款,共 7 个。
- 事件:外界推过来的动作,如付款成功、超时、申请退款。
- 迁移:某状态收到某事件,走到另一状态。
- 动作:迁移发生时系统做的事,退钱、释放库存、结算货款。动作与迁移是两件事:t6 和 t7 共用“结算货款”这个动作,一条迁移也可能有好几个动作。
九条合法迁移,编号如下,后面所有算账都以这份迁移表为底稿:
| 编号 | 起始状态 | 事件 | 目标状态 | 动作 |
|---|---|---|---|---|
| t1 | 待支付 | 付款成功 | 已支付 | 记支付流水、通知商家 |
| t2 | 待支付 | 用户取消 | 已取消 | 释放库存 |
| t3 | 待支付 | 30 分钟超时 | 已取消 | 释放库存 |
| t4 | 已支付 | 商家发货 | 已发货 | 写运单号 |
| t5 | 已支付 | 申请退款 | 退款中 | 冻结金额 |
| t6 | 已发货 | 确认收货 | 已完成 | 结算货款给商家 |
| t7 | 已发货 | 15 天自动确认 | 已完成 | 结算货款给商家 |
| t8 | 退款中 | 商家同意 | 已退款 | 退款给用户 |
| t9 | 退款中 | 商家拒绝 | 已支付 | 解冻金额 |
写“动作”这一列是为了防一种假覆盖:迁移走了,动作没验。t2 和 t3 都进已取消,只测用户取消,就没人验过超时取消有没有释放库存。
再把状态和事件铺成 状态表:行是状态、列是事件,每格填目标状态,空格子就是 非法迁移,即这个状态收到这个事件、系统没有定义该怎么反应1。本例 格,只填了 9 格,另外 54 格都是要表态的地方。铺满这一步和判定表先铺满八列同理:铺满了,空格才看得见。
第二步:三档覆盖,各漏什么
ISTQB 基础级大纲把状态迁移的覆盖分成三档,强弱关系明确:全状态最弱,全部有效迁移是最常用的一档,全部迁移(含非法)要求最高2。
第一档:全状态覆盖,每个状态至少访问一次。三条用例够:
- 付款 → 发货 → 确认收货:走完待支付、已支付、已发货、已完成;
- 用户取消:走完待支付、已取消;
- 付款 → 申请退款 → 商家同意:走完待支付、已支付、退款中、已退款。
七个状态全到,。但实际执行的迁移只有 t1、t2、t4、t5、t6、t8 六条,九条里漏了 t3 超时、t7 自动确认、t9 商家拒绝,即 。
这里有个值得记住的规律:漏掉的三条恰好是最难手工制造的——要等 30 分钟、要等 15 天、要商家点拒绝。状态覆盖率高会给人安全感,而它漏掉的正是最费劲的那部分。
第二档:全部有效迁移覆盖(也叫 0-switch,每条迁移算一步),要求每条合法迁移至少走一次。一条用例可以串起好几条迁移3,所以仍是六条:
- 付款 → 发货 → 确认收货(t1、t4、t6)
- 付款 → 发货 → 等自动确认(t1、t4、t7)
- 付款 → 申请退款 → 商家同意(t1、t5、t8)
- 付款 → 申请退款 → 商家拒绝(t1、t5、t9)
- 超时取消(t3)
- 用户主动取消(t2)
,每条迁移走过、每个动作执行过。多数业务系统做到这一档,再加几条非法的,就够发布用了2。
第三档:路径覆盖,抓第二档看不见的东西——订单是怎么走到这个状态的。
已支付这个状态,从 t1 进来(手里有支付流水)和从 t9 进来(手里有一条被拒的退款记录),名字相同、历史不同。第二档把 t9 走了一遍、把 t5 走了一遍,都验过了;但“先被拒、再申请一次退款”这条连着的路没人走过。
具体到实现:t9 只把状态改回已支付,忘了解冻金额或没还原“可退款金额”,那么商家拒绝一次之后,用户第二次申请退款时金额是 0 或直接报错。t9 单独走一遍看不出来——那一步只验“状态回到已支付、提示拒绝成功”;t5 单独走也正常。两条连起来才暴露。
ISTQB 进阶大纲的工具是 迁移对(1-switch)覆盖:把每个状态上“进入的迁移”与“离开的迁移”两两配对,全部走一遍24。配对数按每个状态(进入数 × 离开数)相加:
- 已支付:进 2(t1、t9)× 出 2(t4、t5)= 4
- 已发货:进 1 × 出 2 = 2
- 退款中:进 1 × 出 2 = 2
合计 8 对。路径必须从初始状态出发,所以要让 8 对全走到,用例会变长:例如“付款 → 申请退款 → 商家拒绝 → 再申请退款 → 商家同意”一条吃掉 4 对,“付款 → 申请退款 → 商家拒绝 → 发货 → 确认收货”吃掉 (t9, t4)。最少 3 条路径就能覆盖 8 对——覆盖项从 9 变成 17,用例数反而少了,因为每条用例变长了。代价不在条数,在于每条更难搭、失败时更难定位是哪一步出的问题。
还有一类顺序缺陷不在这 8 对里,性质相同:t3 之后来了 t1。用户点了取消,支付渠道的延迟回调两分钟后到账,“已取消 + 收到钱”这个模型里没画出来的组合出现了。这类要专门写:取消后允许支付回调到达时,系统要么自动退款、要么恢复订单,最糟的结果是钱扣了、单没了。
第三步:空格子怎么设计用例
非法迁移最容易被随手测一两个就收工。按“全部迁移覆盖”的要求,每个空格都要试一次,而且 一条用例只制造一个非法迁移,否则前一个失败会掩盖后面的问题(缺陷掩盖,defect masking)2。把五个非法操作塞进一条用例,第一个报错弹出后,剩下四个的实际结果你看不见。
动手前先分类,因为空格子的正确期望有三种,混着写必然写歪:
- 该拒绝、状态不变:已取消的订单提交付款,应拒绝并提示,订单仍是已取消。
- 该幂等吸收:同一笔支付回调重放两次、用户连点两下“确认收货”。状态只变一次,第二次应返回成功或静默忽略,而不是报 500、也不是重复结算两次货款。
- 需求漏了:空格子不一定代表“系统该拒绝”。已完成的订单能不能申请售后?业务允许的话,那是模型缺一条 t10,属于需求缺失而非缺陷——这跟判定表里遇到“不可行组合”一样,先问“这格子真造不出来吗”,再决定删还是补。
第二类最容易判错:有人看到“已支付收到付款事件”是空格,就写成“应当报错”。而线上支付回调重放是常态,正确处理是幂等返回成功。期望写错,缺陷报告就成了误报。
还有一点绕不过去:界面上按钮藏起来了,空格子照样要测。已取消的订单页没有“付款”按钮,但支付链接、旧的下单接口可能还生效。非法迁移要在服务端或接口层试,只点界面,你测的是前端有没有把按钮藏好。
第四步:前置条件和边界
状态迁移用例的步骤往往很短(“取消订单”),麻烦全在前置条件——怎么把订单推到那个状态。
要验 t7(发货满 15 天自动确认),等不起,只能改数据库里的发货时间,或要求把超时做成可配置参数(配置改成 10 秒再跑)。t3 同理。t9 需要商家账号或后台权限,单用一个买家账号跑不通。已取消、已退款是一次性终态,测完就废,得能批量造数。把超时能不能配这件事,在写测试点的时候就提出来,比执行时才发现“无法测试”要省事得多。
反过来,两种情况不必动用这套方法。行为没有记忆、同一输入必得同一输出的(表单校验、费率计算),判定表更省事。状态一多也会爆炸:20 个状态 × 8 个事件就是 160 格,迁移对数量按乘积增长,2-switch 以上只适合事件顺序极易出错的高风险系统2。可行的做法是按风险分层:涉及钱、权限、不可逆操作的迁移做“全部有效迁移 + 迁移对”,其余只做全状态覆盖。
最后回到两种方法的分工:判定表管 同一时刻的多条件组合,状态迁移管 先后顺序带来的差别。同一笔订单两者都要用:算钱用判定表,管“什么时候能做、做完还能做什么”用状态迁移。硬把历史条件塞进判定表,列数会失控,而且“上一个状态留下来的数据”整个都会漏掉——那正是路径覆盖唯一能抓到的东西。