# 状态迁移测试：七个状态全走一遍，为什么还漏掉三条最难走的路径

讲清怎样把状态图变成迁移表和状态表，全状态、全有效迁移、迁移对三档覆盖各能抓到什么、会漏掉什么，以及非法迁移和前置条件该怎么设计

> 测试用例设计 · 测试分析 · 黑盒测试 · 约 9 分钟 · 10 月 19 日

## 本篇要点

1. 判断一个功能该不该用状态迁移法，看它的反应是否取决于“之前发生过什么”；同一输入必得同一输出的，仍用判定表。
2. 建模是四步：拆出状态、事件、动作、迁移，画出状态图，落成迁移表，再把状态 × 事件铺满成状态表，空格子就是非法迁移。
3. 覆盖分三档：全状态最弱，本例七状态全过只需 3 条用例，却漏掉九条迁移里的三条，而且漏的正是要等超时、要商家拒绝的那三条。
4. 第二档全有效迁移（0-switch）是常用档位，每条迁移走一次、每个动作执行一次，本例 6 条用例即可。
5. 第三档迁移对（1-switch）把进入与离开同一状态的迁移两两配对，本例 8 对，专抓带着上一状态残留数据出的错：商家拒绝退款后未解冻金额，第二次申请退款时金额为 0。
6. 顺序缺陷还有一类不在配对里：超时取消后支付回调才到账，会出现模型里没有的“已取消且收到钱”。
7. 非法迁移一条用例只试一个，避免缺陷掩盖；期望分三类：拒绝且状态不变、幂等吸收、需求漏写了新迁移。
8. 空格子要在接口或服务端验，界面上藏了按钮不等于入口关掉了；自动触发的迁移只能靠改数据或把超时做成可配置参数才测得到。

---

判定表[article:973b0dc2]解决的是“这一刻几个条件叠在一起算什么”。但订单还有另一个维度，判定表写不下：**同样的操作，之前发生过什么不同，结果就不同。**

用户点“取消订单”，在待支付时是正常操作，在已支付时必须拒绝。“商家同意退款”这个事件，在退款中是把钱退回去，在已完成时根本不该存在。这类“反应取决于当前状态”的功能，要用状态迁移来测。

## 先给出题目

案例是编的，需求四条：

1. 下单后进入**待支付**，30 分钟内未付款自动取消，用户也可主动取消。
2. 付款成功进入**已支付**，商家发货进入**已发货**。
3. 已支付时可申请退款，进入**退款中**；商家同意到**已退款**，拒绝则回到**已支付**。
4. 已发货后，用户确认收货或发货满 15 天自动确认，进入**已完成**。

请先自己列一遍用例，再往下看。

## 第一步：拆出状态、事件、动作、迁移

- **状态**：系统停在哪等事件。这里是待支付、已支付、已发货、已完成、已取消、退款中、已退款，共 7 个。
- **事件**：外界推过来的动作，如付款成功、超时、申请退款。
- **迁移**：某状态收到某事件，走到另一状态。
- **动作**：迁移发生时系统做的事，退钱、释放库存、结算货款。动作与迁移是两件事：t6 和 t7 共用“结算货款”这个动作，一条迁移也可能有好几个动作。

```mermaid
flowchart LR
  A["待支付"] -->|"t1 付款成功"| B["已支付"]
  A -->|"t2 用户取消"| E["已取消"]
  A -->|"t3 30 分钟超时"| E
  B -->|"t4 商家发货"| C["已发货"]
  B -->|"t5 申请退款"| F["退款中"]
  C -->|"t6 确认收货"| D["已完成"]
  C -->|"t7 15 天自动确认"| D
  F -->|"t8 商家同意"| G["已退款"]
  F -->|"t9 商家拒绝"| B
```

九条合法迁移，编号如下，后面所有算账都以这份迁移表为底稿：

| 编号 | 起始状态 | 事件 | 目标状态 | 动作 |
| --- | --- | --- | --- | --- |
| t1 | 待支付 | 付款成功 | 已支付 | 记支付流水、通知商家 |
| t2 | 待支付 | 用户取消 | 已取消 | 释放库存 |
| t3 | 待支付 | 30 分钟超时 | 已取消 | 释放库存 |
| t4 | 已支付 | 商家发货 | 已发货 | 写运单号 |
| t5 | 已支付 | 申请退款 | 退款中 | 冻结金额 |
| t6 | 已发货 | 确认收货 | 已完成 | 结算货款给商家 |
| t7 | 已发货 | 15 天自动确认 | 已完成 | 结算货款给商家 |
| t8 | 退款中 | 商家同意 | 已退款 | 退款给用户 |
| t9 | 退款中 | 商家拒绝 | 已支付 | 解冻金额 |

写“动作”这一列是为了防一种假覆盖：迁移走了，动作没验。t2 和 t3 都进已取消，只测用户取消，就没人验过超时取消有没有释放库存。

再把状态和事件铺成**状态表**：行是状态、列是事件，每格填目标状态，空格子就是**非法迁移**，即这个状态收到这个事件、系统没有定义该怎么反应[1]。本例 $7\times9=63$ 格，只填了 9 格，另外 54 格都是要表态的地方。铺满这一步和判定表先铺满八列同理：铺满了，空格才看得见。

## 第二步：三档覆盖，各漏什么

ISTQB 基础级大纲把状态迁移的覆盖分成三档，强弱关系明确：全状态最弱，全部有效迁移是最常用的一档，全部迁移（含非法）要求最高[2]。

**第一档：全状态覆盖**，每个状态至少访问一次。三条用例够：

- 付款 → 发货 → 确认收货：走完待支付、已支付、已发货、已完成；
- 用户取消：走完待支付、已取消；
- 付款 → 申请退款 → 商家同意：走完待支付、已支付、退款中、已退款。

七个状态全到，$7/7=100\%$。但实际执行的迁移只有 t1、t2、t4、t5、t6、t8 六条，九条里漏了 **t3 超时、t7 自动确认、t9 商家拒绝**，即 $6/9\approx67\%$。

这里有个值得记住的规律：漏掉的三条恰好是最难手工制造的——要等 30 分钟、要等 15 天、要商家点拒绝。**状态覆盖率高会给人安全感，而它漏掉的正是最费劲的那部分。**

**第二档：全部有效迁移覆盖**（也叫 0-switch，每条迁移算一步），要求每条合法迁移至少走一次。一条用例可以串起好几条迁移[3]，所以仍是六条：

1. 付款 → 发货 → 确认收货（t1、t4、t6）
2. 付款 → 发货 → 等自动确认（t1、t4、t7）
3. 付款 → 申请退款 → 商家同意（t1、t5、t8）
4. 付款 → 申请退款 → 商家拒绝（t1、t5、t9）
5. 超时取消（t3）
6. 用户主动取消（t2）

$9/9$，每条迁移走过、每个动作执行过。多数业务系统做到这一档，再加几条非法的，就够发布用了[2]。

**第三档：路径覆盖**，抓第二档看不见的东西——**订单是怎么走到这个状态的。**

已支付这个状态，从 t1 进来（手里有支付流水）和从 t9 进来（手里有一条被拒的退款记录），名字相同、历史不同。第二档把 t9 走了一遍、把 t5 走了一遍，都验过了；但“先被拒、再申请一次退款”这条连着的路没人走过。

具体到实现：t9 只把状态改回已支付，忘了解冻金额或没还原“可退款金额”，那么商家拒绝一次之后，用户第二次申请退款时金额是 0 或直接报错。t9 单独走一遍看不出来——那一步只验“状态回到已支付、提示拒绝成功”；t5 单独走也正常。两条连起来才暴露。

ISTQB 进阶大纲的工具是**迁移对（1-switch）覆盖**：把每个状态上“进入的迁移”与“离开的迁移”两两配对，全部走一遍[2][4]。配对数按每个状态（进入数 × 离开数）相加：

- 已支付：进 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]。把五个非法操作塞进一条用例，第一个报错弹出后，剩下四个的实际结果你看不见。

动手前先分类，因为空格子的正确期望有三种，混着写必然写歪：

1. **该拒绝、状态不变**：已取消的订单提交付款，应拒绝并提示，订单仍是已取消。
2. **该幂等吸收**：同一笔支付回调重放两次、用户连点两下“确认收货”。状态只变一次，第二次应返回成功或静默忽略，而不是报 500、也不是重复结算两次货款。
3. **需求漏了**：空格子不一定代表“系统该拒绝”。已完成的订单能不能申请售后？业务允许的话，那是模型缺一条 t10，属于需求缺失而非缺陷——这跟判定表里遇到“不可行组合”一样，先问“这格子真造不出来吗”，再决定删还是补。

第二类最容易判错：有人看到“已支付收到付款事件”是空格，就写成“应当报错”。而线上支付回调重放是常态，正确处理是幂等返回成功。期望写错，缺陷报告就成了误报。

还有一点绕不过去：界面上按钮藏起来了，空格子照样要测。已取消的订单页没有“付款”按钮，但支付链接、旧的下单接口可能还生效。**非法迁移要在服务端或接口层试**，只点界面，你测的是前端有没有把按钮藏好。

## 第四步：前置条件和边界

状态迁移用例的步骤往往很短（“取消订单”），麻烦全在前置条件——怎么把订单推到那个状态。

要验 t7（发货满 15 天自动确认），等不起，只能改数据库里的发货时间，或要求把超时做成可配置参数（配置改成 10 秒再跑）。t3 同理。t9 需要商家账号或后台权限，单用一个买家账号跑不通。已取消、已退款是一次性终态，测完就废，得能批量造数。把超时能不能配这件事，在写测试点的时候就提出来，比执行时才发现“无法测试”要省事得多。

反过来，两种情况不必动用这套方法。行为没有记忆、同一输入必得同一输出的（表单校验、费率计算），判定表更省事。状态一多也会爆炸：20 个状态 × 8 个事件就是 160 格，迁移对数量按乘积增长，2-switch 以上只适合事件顺序极易出错的高风险系统[2]。可行的做法是按风险分层：涉及钱、权限、不可逆操作的迁移做“全部有效迁移 + 迁移对”，其余只做全状态覆盖。

最后回到两种方法的分工：判定表管**同一时刻的多条件组合**，状态迁移管**先后顺序带来的差别**。同一笔订单两者都要用：算钱用判定表，管“什么时候能做、做完还能做什么”用状态迁移。硬把历史条件塞进判定表，列数会失控，而且“上一个状态留下来的数据”整个都会漏掉——那正是路径覆盖唯一能抓到的东西。

## 术语表

- 状态迁移测试：把功能看成一组状态加触发事件，用一条条“哪个状态收到什么事件走到哪”描述允许的行为，再据此设计用例。
- 状态表：行是状态、列是事件铺满的格子表，填了目标状态的是合法迁移，空格子是非法迁移。
- 有效迁移覆盖（0-switch）：每条合法迁移至少执行一次，最常用的一档，同时保证每个动作被执行过一次。
- 迁移对覆盖（1-switch）：把同一状态上“进入的迁移”和“离开的迁移”两两配对都走一遍，用来查依赖历史残留的缺陷。
- 非法迁移：某状态收到它没有定义反应的业务事件，期望可能是拒绝、幂等忽略，也可能说明需求漏写。
- 缺陷掩盖：一条用例里塞进多个待验的错误行为时，先发生的故障会遮住后面的结果。

## 来源

1. [ISTQB 术语表 — state table 与 state transition testing 的定义，含合法与非法迁移](https://nstqb.org/istqbGlossary.pdf)
2. [引述 ISTQB CTFL v4.0.1 §4.2.4 三档覆盖与“一条用例只试一个非法迁移”的原文，以及 CTAL-TA §3.2.2 的 N-switch 定义](https://tessl.io/registry/testland/state-transition-test-design)
3. [0-switch 与 1-switch 覆盖项定义、迁移对按“进入数 × 离开数”计算、各档包含关系](https://trustedinstitute.com/concept/istqb-ctal-ta/behavior-based-techniques/0-switch-and-1-switch-coverage/)
4. [状态机测试覆盖策略讲义 — 全事件、全状态、全动作、全迁移及包含关系](https://www.cs.helsinki.fi/u/paakki/software-testing-s05-Ch611.pdf)

---

原文：https://pangzhengboyin.com/articles/state-transition-testing-coverage-levels-1a88b77f

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
