
1. 状态迁移图法真正解决的是顺序敏感这一类缺陷先说一个我早年遇到的真实场景。一个订单接口需求文档写得规规矩矩字段列表、参数约束、返回码都很全。我用等价类划分把金额、数量、优惠券这几个字段的合法非法值全排了一遍又用边界值把数量 0/1/最大值附近、金额 0.01/最大值附近都覆盖了跑下来全绿。上线第三天运营群里炸了有个用户点了一次取消订单页面卡了一下他又点了一次结果优惠券退了两张库存回滚了两遍财务对账直接对不上。这个缺陷跟金额多大没关系跟数量是不是边界也没关系。它的问题出在动作发生的先后顺序和系统当时所处的状态上。同一个取消订单请求作用在待支付状态是合法的作用在已取消状态就是重复操作作用在已发货状态则根本不该被接受。等价类、边界值、判定表这些方法都是无状态的——它们默认输入相同输出就应该相同一旦被测对象自身带着记忆、行为取决于历史这套假设就崩了。黑盒测试圈子里把这类方法统称为基于状态的分析其中最常用的载体就是状态迁移图。说白了它就是把被测对象看成一个有限状态机它只可能处在有限个稳定状况里外部事件推动它从一个状况跳到另一个状况跳的时候顺带干点事。测试用例设计要做的就是把跳的过程设计成一步一步可复现、可断言的操作序列。这套方法适合谁我认为三类人收益最大。一是做业务系统测试的同学订单、支付、审批流、工单、账号全都是状态机二是做接口测试用例设计的同学接口之间是靠状态串起来的孤立地测单个接口永远是隔靴搔痒三是做协议和硬件测试的同学通信链路握手、设备上下线本身就是标准的状态迁移模型。这篇文章是状态迁移图法的第一部分我打算把功夫花在怎么建模和怎么导用例这两件事上——这两个环节决定后面所有工作的上限。至于怎么把状态机用例写成自动化脚本、怎么在持续集成里跑起来、怎么处理数据和并发放在第二部分展开。这个拆分顺序很重要模型建歪了脚本写得再漂亮也是在错误的靶子上打洞。1.1 为什么输入输出表式的思维会漏掉整整一类问题我们平时写用例脑子里默认的是一个二维表输入 A 得到输出 X输入 B 得到输出 Y。这个思维模型隐含了两个前提——无记忆和可交换。无记忆的意思是系统不关心你之前干过什么。但真实业务恰恰相反能不能发货取决于订单有没有付过款能不能退款取决于这笔钱有没有真的到账。系统的当前行为是历史路径的函数不是当前输入的函数。可交换的意思是A 然后 B 和 B 然后 A 结果一样。但现实里先支付后取消和先取消后支付是两个完全不同的结局。状态迁移图法之所以必要就是它把历史这个维度显式地画了出来。图上的每一条边都自带前置条件——你必须先站在起点状态这条边才成立。这一点是无状态方法永远补不上的。1.2 状态迁移图法能精准打中的三类缺陷我在实际项目里统计过从状态机模型导出来的用例命中的缺陷大致集中在三类。第一类是非法迁移未被拦截。系统在某些状态下收到不该处理的事件时没有拒绝而是好心地执行了。比如已完成的订单又被提交了一次取消系统扣款成功但订单状态没变钱卡在中间。这类问题在人工点测时极难发现因为正常人不会去点那个按钮——但接口不会跟你客气。第二类是顺序错乱导致的重复副作用。上面说的优惠券退两次就是典型。它往往伴随着重试、双击、消息重复投递这类现实世界的噪音。第三类是状态卡死或丢失。系统进入了某个状态但没有任何路径能出来。或者因为异常中断业务状态和数据状态不一致——数据库里订单已经是已支付但支付流水还是处理中两边永远对不齐。这三类缺陷有个共同点它们都不体现在单个接口的返回值里只体现在状态流转的轨迹里。你盯着响应体看是看不出来的必须把整个轨迹拉出来断言。2. 把需求文档翻译成模型状态、事件、迁移、动作四件套建模这件事听起来玄其实就是做翻译。把需求文档里的自然语言翻译成四个基本元素的组合。这四个元素是状态、事件、迁移、动作。有的人还会加第五个——守卫条件我认为它是事件的一部分后面单说。先说状态。状态是被测系统在某一时刻所处的稳定状况。这里有个关键词是稳定。鼠标悬停、加载中的转圈这些是瞬时表现不是业务状态。判断标准很简单如果系统会停在这个状况那里等你直到下一个事件到来它才动那它就是个状态如果它自己会立刻往下走那它就是一个动作或者中间态不该画成一个圆。然后是事件也叫激励。它是推动状态变化的触发源。事件分两类一类来自外部——用户点击、接口调用、消息到达、第三方回调另一类来自内部——定时器到期、重试次数耗尽。做接口测试的同学特别容易漏掉第二类因为接口文档里根本不写它但它对状态的影响巨大。迁移是状态和事件的组合结果写成一条边在状态 S 下收到事件 E跳到状态 S。动作是这条边上附带的事情比如扣库存、发短信、写流水。动作和状态变化不一定同生共死——有的动作在状态变化前执行有的在后面有的在失败时会回滚。把这四件事摆清楚一张图就出来了。但要摆得准坑在后面几节。2.1 状态怎么切稳定态与瞬时态的那条分界线状态粒度是建模范畴里最考验经验的地方没有之一。切得太粗会漏掉缺陷。我见过一个很典型的例子把整个支付过程统一叫支付中不管是在等待第三方返回、还是在等待回调通知、还是在等待对账确认。结果线上出现用户重复支付的缺陷测试同学一脸茫然——在他眼里状态一直是支付中没变过啊。实际上这几个子阶段的并发行为和超时行为完全不同必须拆开。切得太细会让模型爆炸。一张图上百个状态谁都看不懂维护成本高到没人愿意更新最后必然烂尾。我自己的经验法则是看该状态下允许的事件集合。如果两个状况下允许被触发的事件集合是一样的那它们对测试而言就是同一个状态可以合并如果事件集合不同说明系统对它们的行为有区分那就该拆开。拿订单来说。待支付和支付确认中允许的事件明显不同前者可以取消、可以改地址后者都不行。所以必须分开。而已支付待发货和已支付已分配仓库如果两者的允许事件完全一样都只能申请退款那对黑盒测试来说就可以合并成一个状态。这里还要提一句初始状态和终态。初始状态是模型运行的起点用例设计时要明确怎么把系统置到初始状态。终态是走不出来的状态写完终态要反问自己一句真的没有出边吗很多系统的终态其实是有出边的比如已完成可以有申请售后这条边只是需求文档没写而已。2.2 事件、守卫条件与动作迁移边上到底该写什么一条边上的标注完整的写法是事件 [守卫条件] / 动作。守卫条件是事件触发迁移的必要前提。举个生活化的例子门禁刷卡这个事件能不能开门还取决于卡是不是有效的、是不是在有效时段内。这些判断就是守卫条件。它和状态一起构成迁移成立的必要条件。守卫条件在测试里非常容易变成盲区。因为它的组合数远多于状态数。一个提交审批事件守卫条件可能有提交人是否为直属主管、金额是否超过阈值、是否在预算周期内。三个条件两两组合就是八种情况。你要测的不是事件本身而是这些守卫的真假组合。动作也要写清楚而且要区分可观察的动作和内部动作。可观察的动作是黑盒测试的断言点——比如发了一条消息、写了一条流水、调用了一次第三方。内部动作像更新缓存、打点埋点黑盒层面看不到可以不画进模型但在做集成测试时要心里有数。有个细节值得单独强调动作失败怎么办。一个支付成功事件触发的动作可能包括更新订单状态和扣减库存如果扣库存失败了订单状态要不要回滚这类分支如果需求文档没写一定要去问因为它是缺陷的高发地带而你在图上不标出来用例就不可能覆盖到。2.3 一张订单状态机的草稿推演纸上谈兵不如画一张。我用一个电商订单的简化版本来演示推演过程。状态先列出来待支付、已支付待发货、已发货、已签收已完成、已取消、退款中、已退款。事件列出来用户支付、支付超时、用户取消、商家发货、用户签收、用户申请退款、退款成功、退款失败。然后逐个状态推演把每个状态的可能性走一遍。待支付状态下收到用户支付跳到已支付待发货收到用户取消跳到已取消收到支付超时跳到已取消动作是释放库存。收到商家发货不应该有反应这是无效迁移。已支付待发货状态下收到商家发货跳到已发货收到用户申请退款跳到退款中收到用户取消拒绝——这里就是很多人会纠结的地方业务上已支付未发货能不能直接取消其实等同于申请退款所以要走退款中这个状态而不是直接跳到已取消。已发货状态下收到用户签收跳到已完成收到用户申请退款拒绝——需要先走退货流程模型里可能得加一个退货中状态。你看推演的过程就是在不断发现我原本以为有其实没有和我原本以为没有其实有。退款中状态下收到退款成功跳到已退款收到退款失败回到已支付待发货——这里就出现了一条回流边它是一开始最容易漏掉的。因为大部分人画图时只想着往前走。推完这张草稿你会发现它已经不是一条直线而是一张有环、有分支、有回流的网。这就是为什么它值得单独画出来而不是靠脑子记。3. 从模型到用例覆盖准则的五个层级与性价比拐点图画完了接下来是从图里抽用例。抽多少、抽到什么程度靠的是覆盖准则。这块是有理论支撑的不用自己拍脑袋。从弱到强大致是五个层级。状态覆盖每个状态至少被访问一次。这是最弱的一般只作为兜底单独用没有意义——你把七个状态都走一遍可能只走了七条边而图里有二十条边。迁移覆盖也叫 0-switch每条合法迁移至少执行一次。这是工业界的事实标准也是最常被引用的准则。用例数大致等于合法迁移数如果设计得好一条用例可以串联多条迁移实际用例数还能压缩。迁移对覆盖1-switch每两条相邻的迁移组合至少执行一次。它专门用来抓状态之间的接口问题——单个迁移都对但连着做就错。比如支付成功后立刻取消这一步单独看都是合法的组合起来却暴露了数据不一致。路径覆盖所有从初始状态到终态的完整路径都走一遍。理论上最彻底实际上组合爆炸除了极小规模的模型没人能做完。N-switch迁移对覆盖的推广连续 N 条迁移的组合。实际项目里 N 取到 1 就已经很够了。我自己的取舍很明确合法迁移全覆盖关键路径上的迁移对重点覆盖无效迁移一个不落。这个策略在绝大多数业务系统上性价比最高。3.1 迁移对覆盖为什么是性价比拐点有同学会问既然路径覆盖最彻底为什么不做算笔账。假设一个模型有 10 个状态、平均每个状态 3 条出边总边数 30。长度 3 的路径粗略估算是 30 的 3 次方再打折量级已经在千以上。而迁移对覆盖只需要看每个中间状态的入边乘出边10 个状态平均下来是 9×9 的两两组合再收敛量级在百以内。差了整整一个数量级。从缺陷发现率看我在几个项目上做过粗略对比迁移覆盖能抓到大约七成的状态相关缺陷加上迁移对覆盖能提到九成上下再往上加路径覆盖边际收益急剧下降而用例维护成本线性上升。所以我的建议很直接预算有限就做迁移覆盖加无效迁移预算宽裕就把关键业务链路上的迁移对补上。所谓关键链路就是那些一旦出错会涉及资金、库存、权限的路径。3.2 组合爆炸的止血办法等价路径合并即便只做迁移覆盖某些模型还是大到吓人。这时候有几招止血。第一招是合并等价路径。如果两条路径经过的状态不同但对被测系统来说可观察行为完全一致就可以合并。判断依据是断言点是否相同——断言不出来差别的东西测它没有意义。第二招是参数化。很多迁移在业务语义上是同一条只是走的入口不同。比如支付成功这个事件从微信支付、支付宝、银行卡三个渠道进来如果三个渠道在状态机层面行为一致就应该写成一条带参数的迁移而不是三条。这里要注意一个反直觉的点渠道差异恰恰是缺陷重灾区所以能不能合并必须先去确认三个渠道的回调时序、超时时间、重试策略是不是真的完全一致。不一致就老老实实拆开。第三招是用状态迁移表代替图来检查遗漏。图和表是一体两面图直观但容易漏边表枯燥但能穷举。下一节细说。3.3 用状态迁移表把图上看不见的线补齐状态迁移表是这么组织的行是状态列是事件交叉的单元格写目标状态和动作。关键点在于单元格不允许留空。每一个格子必须明确写上是有效迁移还是无效迁移。这就是状态迁移图法里最容易被低估的一步。图上你只能画你想到的线想不到的线就不存在表里每个格子都在强迫你做决定——待支付状态收到签收事件会怎样这一步补出来的东西往往就是最有价值的用例。我给你看个真实的片段当前状态用户取消商家发货用户签收申请退款待支付已取消无效报错保持原状态无效报错保持原状态无效报错保持原状态已支付待发货转退款中已发货无效报错保持原状态退款中已发货无效报错保持原状态无效报错保持原状态已完成转退货流程已取消无效幂等返回成功无效报错无效报错无效报错表里那些无效报错的格子每一个都是一条待设计用例。而且注意我特意区分了两种无效一种是直接报错另一种是幂等返回成功。已取消状态下再次收到取消请求业务上应该返回成功而不报错否则用户重试会看到莫名其妙的失败提示。这种细节只有把表填满的时候才会逼你想清楚。提示填表时遇到拿不准的格子不要自己拍。标个问号拿去问产品。一个模型里通常有 5 到 10 个这样的模糊点它们对应的正是需求文档里没写清楚的地方——这些地方上线后必然出问题。4. 无效迁移才是缺陷重灾区合法迁移的用例好写因为需求文档里写了。无效迁移的用例难写因为文档里通常一个字都没有。但恰恰是这一块藏着我见过最多的线上事故。无效迁移大致分四类每类的测法不一样。第一类叫越权迁移就是不该能跳的跳过去了。待支付订单直接跳到已发货或者普通用户把别人的订单状态改了。这类用例要设计成绕过前端直接发接口请求因为前端会把不该有的按钮藏起来你看不到问题。这也是接口测试比界面测试更值得投入的地方。第二类叫重复迁移就是同一个事件来了两次。用户双击、网络超时后客户端重试、消息中间件重复投递都会造成这个。这类用例的核心是幂等性验证同一请求连续发两次状态只应该变一次副作用只应该发生一次。第三类叫乱序迁移事件的到达顺序被打乱。用户签收的事件可能先于发货事件到达如果中间有消息队列且分区不同系统必须能扛住。这类问题在多服务架构里特别常见测的时候要看系统有没有做版本号或者状态先决条件校验。第四类叫终态逃逸从终态又跳出去了。已取消的订单还能被支付——这是真实发生过的缺陷用户在两台设备上操作一台点了取消另一台还停留在支付页直接付款成功。系统没有任何一条路径能从已取消到已支付但钱实实在在扣了。4.1 每一种状态×事件组合都要有明确预期前面说的状态迁移表就是给这件事做的准备。表格填满之后导出用例是机械动作每个无效格子对应一条负向用例每个有效格子对应一条正向用例再加上适当的迁移对组合作为补充。这里有个执行细节值得说。无效迁移用例的断言不能只断接口返回了失败。真正的断言应该是三件事同时成立接口返回了预期的错误码、业务状态保持在原状态未变、副作用没有发生。我见过太多用例只断了第一件事结果系统返回了错误码但库存已经扣了测试照样绿灯。副作用怎么断言看你的系统有什么可观察的出口查流水表、查库存服务的接口、查消息队列有没有新消息。正常情况下这些不该有变化。这一条写进用例里能拦掉一大批脏数据缺陷。4.2 接口层面验证非法迁移的三种构造手法做接口测试的同学具体怎么把系统置到某个状态下收到某个事件这个场景里三种手法。第一种是调用前置接口把系统推到目标状态然后再发被测事件。这是最贴近真实的手法也是最慢的因为推状态本身要花时间。优点是路径干净不依赖内部实现。第二种是直接操作数据用数据库脚本把状态字段改成目标值。速度快但风险在于你可能漏改了关联数据导致测的不是真场景。用这招的前提是你对数据模型足够熟。第三种是Mock 或桩把外部依赖的返回固定住从而让系统进入特定状态。比如把第三方支付回调的 mock 设成失败系统自然就进到退款失败那条分支。这招在测异常分支时效率极高。我的习惯是组合用主流程用第一种异常分支用第三种兜底用第二种而且第二种只用来快速验证不写进正式的回归用例集。5. 接口测试用例怎么用状态机串起来单独聊一下接口测试这个场景因为这是问得最多的。大部分同学写接口用例的思路是一个接口一个用例输入几组参数看返回对不对。这个思路做参数校验没问题但它测不了业务。业务是由一串接口按顺序调用构成的真正的缺陷藏在这一串调用的接缝处。我的做法是以状态机为主线组织用例接口只是迁移的实现手段。具体来说先确定要覆盖的那条状态链路比如创建订单→支付→发货→签收。然后把链路上每一步对应的接口找出来。接着按迁移设计用例——不按接口设计用例接口的入参组合只在每个迁移内部做变化。这样组织有个很大的好处用例名称是可读的。以前叫订单接口测试_001现在叫从待支付经支付迁移到待发货_正常支付。半年后回来看前者你完全不知道测了什么后者一眼就明白。5.1 状态前置条件的构造决定了脚本跑得快不快自动化跑得慢八成慢在状态构造上。举个例子要测已发货订单的签收你得先有一个已发货的订单。如果老老实实走完整流程创建、支付、等回调、发货走下来可能十几秒。而一个回归集里有几十条用例需要这个前置状态累积起来就是十几分钟。我的做法是把状态构造封装成独立的工具方法每条用例声明自己需要什么状态由工具方法去准备。工具方法内部可以走捷径——直接改库、或者调用一个内部的状态操作接口很多系统都有后台运营工具可以改状态。这个投入非常划算一次写好后面所有用例都受益。同时要保证用例之间不互相污染。每条用例要么用独立的数据比如每次生成新订单号要么在跑之前把数据重置。我踩过的坑是两条用例共用一个订单跑单条都过一起跑就挂排查了两个小时才发现是状态被前一条用例改了。注意用数据库直改状态做前置一定要在测试报告里标注出来。因为这种手法绕过了业务校验一旦被测系统的状态校验逻辑本身有 bug被绕过去的问题就永远不会暴露。所以这类用例只能作为补充不能替代真实链路用例。5.2 状态断言该断什么三个层次缺一不可接口测试里最常犯的错是只断响应体。响应体说支付成功你就信了。实际上响应体只是系统的一种表态真正的状态可能存在好几个地方而它们完全可能不一致。我的断言清单是三层。第一层是接口返回状态码、业务错误码、返回体里的状态字段。第二层是持久化状态查一下订单表的状态字段是不是也变了。很多缺陷就是这一层露馅的——接口返回成功但事务回滚了库里没变。第三层是下游副作用库存扣了没、优惠券核销了没、消息发出去了没。这一层最容易被忽略但它恰恰是资金类缺陷的重灾区。三层都断用例才算完整。写起来是麻烦一点但一次写好可以复用成本没有想象中高。6. 三种典型状态机的建模差异状态机不是只有一种形状。我按形状把常见的分成三类每类的建模重点完全不同。第一种是单向收敛型。工单、审批、实名认证都是这类从初始状态出发一路往前走最终收敛到通过或者驳回。这类模型的特点是分支少、路径短重点测的是守卫条件——不同金额、不同角色、不同部门走的路不一样用例量主要来自守卫条件的组合而不是状态本身。第二种是带回流的分支型。订单、退款、售后属于这类。它有多条回边的存在比如退款失败回到原状态、审批驳回回到上一节点。这类模型的重点是回流边和终止条件——回流之后是新起一轮还是续上原来那轮驳回三次之后怎么办这些是需求最容易写不清楚的地方也是缺陷最多的地方。第三种是并行与超时驱动型。设备在线状态、会话状态属于这类。它有两个特点一是可能有多个实例同时持有同一个状态同一账号在多个设备登录二是状态的迁移很大一部分由定时任务驱动心跳超时判定离线。这类模型的重点是并发和时钟——两个实例同时上报会不会互相覆盖心跳超时的时间阈值是不是所有地方一致分清形状之后你会发现状态迁移图法的用例设计不是一套固定动作而是三套不同的侧重。搞错了侧重就会把大量预算花在不重要的事情上。7. 建模和执行中最容易踩的五个坑这一节全是我自己踩过的写出来给大家省点时间。第一个坑把实现细节当成业务状态。我在图上画过已发送MQ消息未消费这种状态。问题在于这个状态纯粹是实现层的业务方根本不认。后来系统换了消息中间件这个状态名就不存在了整张图重画。教训是画图之前先问一句这个状态业务方会承认吗会就去问产品不会就从图里删掉。第二个坑只画正向边忘了回流。前面反复说过不再展开。具体做法是每画完一条正向边就强迫自己问一句从这个新状态能回到哪去。第三个坑忽略了时间这个维度。很多状态迁移不是被人触发的是被时间触发的。下单 15 分钟未支付自动关闭这个迁移在图上通常没有对应的事件因为它没有外部请求。如果不显式画一条超时事件这块逻辑永远不会被测试覆盖。而且测超时不能真等。常见解法有三种把超时时间做成配置项测试环境调成 5 秒提供一个手动触发定时任务的接口或者直接从数据层把创建时间往前改 20 分钟等下一次扫描。三种各有适用场景我一般优先用第一种因为它顺带验证了配置项本身能不能生效。第四个坑状态和数据的双写不一致。状态迁移往往伴随着多个数据变更订单状态变了、库存变了、优惠券变了、流水加了一条。如果这些操作不在一个事务里中途失败就会留下半新半旧的数据。这种缺陷在正常路径上永远测不出来必须靠异常注入——在某个动作执行到一半的时候让下游报错看系统能不能正确回滚。这块属于状态迁移法的高级用法第二部分会详细讲怎么做。第五个坑图建完就锁死不跟着需求更新。需求改了状态加了两个但图还是老的。于是新状态的用例一条都没有。这件事没有技术解法只能靠流程把状态迁移图作为测试用例评审的必看材料只要需求变更涉及状态就必须同步更新图。我在团队里试过一个土办法——把状态迁移图贴在需求评审的模板里每个需求过来必须填一遍新增/修改/删除了哪些状态和迁移。填不出来就说明需求没想清楚。最后再分享一个小技巧。建模的时候我喜欢用一张白纸先把所有状态名写成一列再把所有事件名写成一排画出那个矩阵然后一个一个格子填。填不出来的格子标上问号填完的格子打钩。等所有格子都有答案了图自然就出来了而且不会有遗漏。这个方法看着笨但比直接画图可靠得多尤其是模型有十几个状态的时候。至于画图工具纸笔就够真要电子化画图也行重点从来不在工具上而在你有没有把每个格子都填满。