ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于模型的测试(MBT)实战:状态机建模与用例自动生成

基于模型的测试(MBT)实战:状态机建模与用例自动生成

1. 为什么我会推荐把MBT放进测试技术栈

1.1 传统用例设计的三道坎

做测试这行时间长了,你会慢慢发现一个有意思的现象:真正让人头疼的不是测试执行,而是测试设计。

需求评审完了,开发方案定了,接下来就是用例设计。但用例写得好不好,很大程度上取决于个人经验。有人能想到异常的排列组合,有人只能覆盖到主流程。这份依赖个人经验的“手艺活”,在项目交付压力上来之后,很容易变成走过场:补几个主流程、加几个异常分支,就算交差。至于系统里那些交错的状态流转、边界条件、组合路径,漏掉是常态,不漏才是运气。

第二个坎是覆盖度没法度量。执行完之后,团队能拿出来的数字往往是“用例总数、通过率、缺陷数”,但很少有人能回答一个问题:核心业务状态流转,我们的用例到底覆盖了百分之多少?代码覆盖率可以靠工具测出来,但业务行为覆盖度,传统用例设计里几乎没有抓手。

第三个坎是需求变更的连带成本。系统状态一调整,接口一改,线下设计好的用例就要人工逐条改。改的时候靠人肉判断哪些用例受影响,很容易顾此失彼。我见过不少团队,需求从一版翻到二版时,用例库里躺着一堆已经失效的步骤,没人清理,也没人发现。

这三道坎叠在一起,结果就是测试设计变成了“既费人、又难量化、还怕变更”的环节。这也是我后来认真去研究和落地基于模型的测试(MBT,Model-Based Testing)的根本原因。

1.2 MBT到底做了什么改变

MBT的核心思路,是把“写用例”这件事拆成两步:先建模型,再生成用例。

具体来说,建模阶段把被测系统的关键行为用一种结构化的形式表达出来,比如状态机、流程图、决策表。模型里清晰地写清楚系统有哪些状态、哪些动作能触发状态变化、哪些条件下流转成立、哪些数据组合会产生什么结果。建完模型之后,测试用例的生成就从“人脑推演”变成了“算法遍历”,由工具基于模型自动跑出满足指定覆盖准则的路径和条件组合。

这一步转变带来的好处是结构性的。

第一,覆盖度可以量化和追溯。模型里的每个状态、每条边都对应一个业务行为节点,生成的用例覆盖了百分之多少的状态、多少条迁移路径,一清二楚。这个数字直接回答了我刚才说的那个痛点:核心业务行为到底测够了没有。

第二,测试设计和实现解耦。模型变了,重新生成一套用例就行。底层负责执行的部分单独维护,不受用例变更影响。需求的修改落到模型文件上,比跑到用例库里逐条改要快得多,也不容易漏。

第三,用例设计从“凭经验”变成“靠结构”。模型把需求里的显性规则和隐性分支都摆到台面上,评审模型本身就是评审需求。很多需求里的漏洞,在建模阶段就会暴露出来,根本等不到运行阶段。

我用一个生活化的类比来解释:传统测试设计像是工人拿着一张手绘清单去仓库拣货,清单漏了哪项,货就漏拣了;MBT则是先把仓库货物分布画成一张地图,再让机器按路线自动生成拣货清单。地图只要画得准,清单的完备性就有了保障。

1.3 谁最适合先试水

MBT不是银弹,不是所有系统都适合一上来就建模生成用例。从我落地项目的经验看,最适合先试水的对象有几个特征。

第一,业务状态和流程复杂度高于普通CRUD。登录态、订单状态流转、审批流程、设备状态切换,这类“翻来覆去地变状态”的业务,用模型表达收益最大。如果系统绝大部分接口都是无状态的增删改查,那传统用例设计就够了,上MBT反而多余。

第二,团队有稳定且可回归的测试目标。没有自动化执行能力,MBT生成的用例只能停留在文档层面。反过来想,MBT本来就是为自动化回归设计的,手工执行生成的批量用例,体验不会好。

第三,模型维护的投入能换来真实回报。说白了,系统迭代频繁,流程复用度高,模型的价值就大。如果系统半年才发一次版,改动又小,模型就算建出来也是“躺着睡大觉”。

我比较推荐的切入路径是:先挑一个状态复杂、缺陷风险高的小模块试水,把模型建起来、工具链跑通,再用结果去说服团队扩大范围。一上来就铺全量,建模工作量会把团队吓退。

2. 建模选型:模型不是画漂亮的图,而是可执行的测试规格

2.1 先搞清模型的三个基本组成

MBT里的模型,听起来抽象,落到实践上其实就三类要素:状态、动作、守卫条件。

状态,描述的是系统在某个时刻的稳定情形。比如“订单待支付”“订单已支付”“订单已取消”,这些都属于状态。状态是模型里的节点。

动作,是导致状态发生迁移的事件。用户点击“立即支付”、定时任务触发“超时关单”,这些动作对应模型里的边,也就是从当前状态到下一个状态的迁移弧。

守卫条件,决定了动作在当前状态下是否真的能生效。比如“仅当订单归属当前用户且订单状态为待支付时,取消操作才被允许”。“当前用户是订单归属人”和“订单状态为待支付”就是两条守卫条件。条件不满足,动作即便发生了,也不能触发迁移。

把这三类要素放到一张图里,就是一个非常标准的状态机模型。我见过不少刚入门的人,建模时喜欢把页面上所有的按钮都摆进去,结果把模型画成一张密密麻麻的蜘蛛网。问题的根源在于——弄混了“动作”和“操作入口”。按钮是操作入口,但真正推动业务往前走的是动作背后的业务语义。比如“提交订单”和“提交失败重试”,在模型里可能是同一个动作的两种守卫分支,不该劈成两个节点来表达。

建模前先问自己一个问题:这个动作发生之后,系统的业务状态到底变没变?没变的,别往模型里塞。这个原则能帮你砍掉至少三成无效内容。

2.2 常用模型视图怎么选

不同业务形态,适合的表达模型也不同。我列一个简表,方便对照着选。

模型类型表达重点适合的场景产出用例类型
有限状态机(FSM)状态、事件、迁移、守卫订单、审批、协议状态机等强状态业务状态覆盖、迁移覆盖、路径覆盖
活动图/流程图业务流程、分支、合并、并行端到端业务流程、跨系统交互流程流程路径覆盖、条件组合
决策表/因果图条件组合与结果之间的映射规则引擎、促销计算、费用计算等强逻辑业务条件组合覆盖、MC/DC变体
时序图模型消息交互顺序、超时、重发消息中间件、通信协议、分布式交互消息序列覆盖、超时场景

选型的时候不要贪多。一个模块用一种主模型表达清楚,比混用两三种模型更可控。我的习惯是:先看业务是否有“明显的状态集合”,如果有,直接用状态机;如果状态感不强、但流程分支复杂,就用活动图;如果业务本质是“一堆条件算出一堆结果”,比如优惠计算、费率计算,决策表是效率最高的。

这里要特别提醒一个点:模型最终要能被工具解析,不是画给人看就完了。所以建模动作一定发生在工具链支持的范围里。建模之前先看工具支持哪些模型,别在画图工具里画了一堆漂亮的图,最后没法导入生成器。

2.3 抽象级别的选择是建模成败的分水岭

模型该建到多细,是MBT落地中最容易失衡的地方。

建模过细,细到页面上每个输入框的每种校验,模型会迅速膨胀,状态爆炸、边数激增,生成出来的用例也长得没法看。建模过粗,又表达不出关键业务逻辑,生成的用例全部点到为止,覆盖不到真正的风险点。

判断抽象级别是否合适的标准,我用了很久,确实靠谱:模型里的一个迁移,能否对应到测试实现里的一个“验证步骤”。如果能,那这个抽象层次是合适的。比如“用户发起退款申请”,对应到系统里就是调一个退款接口、传入订单号并携带会话凭证,验证返回字段“退款单号”不为空。这就是一个可映射的迁移。如果“用户发起退款申请”对应到界面上要操作五个控件、等三个异步回调,那就说明抽象层级偏低了,要把这些动作封装成一个“用户动作”来建模,不然适配层会写得非常痛苦。

另外一个容易被忽略的点是:模型是给测试用的,不是给需求文档补充说明的。不需要把所有非测试关注点的内容都塞进来。跟这次测试目标无关的状态细节,可以大胆砍掉。测试关注谁的迁移,模型就保留谁。

3. 实操核心:用开源工具把模型变成用例

3.1 定义状态机与守卫条件的落地写法

理论讲了这么多,真正动手建模型的时候,写法是关键。这里以开源工具GraphWalker为例,它在MBT工具链里算是比较容易上手的一款,模型用JSON或GraphML描述,输入模型再加上覆盖准则,就能输出可执行的测试路径。

先看一个极简的登录状态机模型,感受一下JSON的书写方式。

{ "name": "loginModel", "generator": "random(edge_coverage(100))", "model": { "states": [ {"name": "LoggedOut"}, {"name": "LoggingIn"}, {"name": "LoggedIn"} ], "edges": [ {"name": "submitCredentials", "sourceState": "LoggedOut", "targetState": "LoggingIn", "weight": "1"}, {"name": "loginAccepted", "sourceState": "LoggingIn", "targetState": "LoggedIn", "weight": "1"}, {"name": "loginRejected", "sourceState": "LoggingIn", "targetState": "LoggedOut", "weight": "1"} ] } }

上面的JSON描述了三个状态:未登录、登录中、已登录。三条边分别对应“提交凭证”“登录成功”“登录失败”。generator那一行,random(edge_coverage(100))的意思是:用随机策略生成路径,目标是覆盖全部100%的迁移边。

很多人第一次看到这个JSON会问:为什么没有写守卫条件?GraphWalker这类工具的通用做法是,把守卫条件的判断逻辑放在测试实现里,也就是适配层。模型层面通常只描述结构化的“候选路径”,而“这条路径能不能真正走通”,由适配层的代码做运行时判定。

这个设计初看觉得麻烦,细想其实是合理的:模型保持足够的抽象度,就不容易被底层实现细节绑架。如果系统从接口调用改成消息触发,只要业务状态没变,模型还是那份模型,改的只是适配层。

3.2 覆盖准则的选择与生成策略

模型可以生成无限多条路径,但没人会跑无限多。所以MBT里必须有一个“收口”的动作,这个动作就是选择覆盖准则。

最常见的几个准则,按性价比排序,我先从低到高说。

状态覆盖,要求生成的路径把这些状态都走到至少一次。这是最弱的准则,一般只用来做冒烟测试。迁移覆盖,要求每条迁移边都至少被走过一次,这是MBT落地的底线和默认选项,对应到业务上就是“每个业务动作的每个流向分支都被验证过”。多条件覆盖,要求守卫条件里的每个原子条件分别取真和假,适合规则敏感型业务。路径覆盖则穷举所有可达路径的组合,代价最高,一般只在核心模块的少量关键路径上应用。

我在实际项目里,极少直接用全路径覆盖,那会带来严重的组合爆炸。通用的打法是:迁移覆盖打底,再用条件覆盖补关键业务规则,最后手工补充几条模型没有覆盖到的端到端异常场景。

用GraphWalker的话,generator的写法可以这样配:

# 迁移覆盖 generator = "quick_random(edge_coverage(100))" # 状态覆盖,适合快速冒烟 generator = "random(vertex_coverage(100))" # 条件覆盖,适合规则型业务,注意模型需支持条件表达 generator = "random(condition_coverage(100))"

关于生成算法,我个人的观察是:随机策略在大多数业务场景下反而是最优解。原因在于,确定性深度优先算法生成的路径序列往往比较固定,容易偏向某几个分支组合,造成覆盖盲区;随机策略配合覆盖向导,能在同样的路径长度下探索到更多组合。

3.3 适配层设计决定自动化成败

MBT的模型生成出来的,本质上是“带逻辑步骤的路径描述”。要让这些路径真正跑起来,必须有一个适配层,把模型里的每个动作翻译成真实的系统操作。

我习惯把适配层设计成一张“动作映射表”。模型里的动作名是抽象名,适配层里提供同名的方法,方法内部去调用真实接口或操作UI。以登录为例,模型里的“submitCredentials”动作,在适配层的实现可能长这样:

class LoginAdapter: def submit_credentials(self, account, token): resp = auth_api.login(account, token) return resp.status_code == 200 # 返回布尔值,供守卫条件判断

这里有一个很有争议但实际有效的实践:把适配层的“断言”尽量后置。也就是说,适配层方法只负责执行动作和返回结果特征,真正的业务断言放到路径执行完之后统一做。原因在于,MBT生成的是一个连续路径,你在中间某个迁移处卡了断言,可能因为环境时序问题产生误报,而把断言集中到路径终点,反而能更准确地评估整条业务链路的正确性。

适配层要不要用UI自动化?我的建议是:优先接口层。原因太现实了——UI自动化本身就带一大堆稳定性问题,再叠加MBT的随机路径,你会分不清是产品出问题了,还是元素没找到,还是路径设计有问题。接口层的适配层要稳定得多,适合先把整个MBT链路打通。打通之后,如果有端到端验证的硬需求,再在少量关键路径上接UI操作。

4. 完整小案例:登录态下购物车下单流程的MBT落地

4.1 业务与模型定义

空讲概念没有说服力,我用一个带登录态的购物车下单流程,把整条MBT链路完整走一遍。

这个系统的核心业务逻辑是这样的:用户下单前需要先登录,未登录时只能浏览和加购,不能提交订单;购物车有商品才能进入结算,结算时校验商品库存和用户地址,任一不满足就不能提交成功;订单提交成功后就进入“待支付”状态,超时未支付自动取消。

整理一下关键状态:

  • 未登录(LoggedOut)
  • 购物车有商品(CartNonEmpty)
  • 结算中(Checkout)
  • 订单已提交(OrderSubmitted)
  • 订单已支付(OrderPaid)
  • 订单已取消(OrderCancelled)

关键动作和守卫条件:加购动作,仅当未登录或已登录均可,加购后进入购物车有商品状态;提交订单,仅当已登录且购物车有商品且有有效地址,进入结算中;结算成功,仅当库存和地址校验通过,进入订单已提交;支付成功,仅当订单归属当前用户,进入订单已支付;超时未支付,进入订单已取消。

注意,一个容易出错的地方:模型里的守卫条件我不建议直接写判断逻辑,而是通过生成器和适配层配合实现。GraphWalker这类工具在运行时由适配层执行“这个动作能否发生”的判断,所以建模阶段只需要把“可能发生的迁移”表达出来即可。

4.2 模型文件与生成结果

对应的GraphWalker简化模型,我写成下面这样:

{ "name": "orderFlowModel", "generator": "quick_random(edge_coverage(100))", "model": { "states": [ {"name": "LoggedOut"}, {"name": "CartNonEmpty"}, {"name": "Checkout"}, {"name": "OrderSubmitted"}, {"name": "OrderPaid"}, {"name": "OrderCancelled"} ], "edges": [ {"name": "browseAndAddCart", "sourceState": "LoggedOut", "targetState": "CartNonEmpty", "weight": "1"}, {"name": "login", "sourceState": "CartNonEmpty", "targetState": "CartNonEmpty", "weight": "1"}, {"name": "goCheckout", "sourceState": "CartNonEmpty", "targetState": "Checkout", "weight": "1"}, {"name": "submitOrderSuccess", "sourceState": "Checkout", "targetState": "OrderSubmitted", "weight": "1"}, {"name": "payOrder", "sourceState": "OrderSubmitted", "targetState": "OrderPaid", "weight": "1"}, {"name": "orderTimeoutCancel", "sourceState": "OrderSubmitted", "targetState": "OrderCancelled", "weight": "1"} ] } }

这个模型没有画出登录失败的迁移,是刻意为之——这次测试目标的重点落在下单支付主链路,登录失败分支由另一套专门的登录状态机覆盖。模型设计时做适当的剪裁,可以让跨界面的测试焦点更集中,这也是前面提到的抽象级别选择在实操中的体现。

生成结果通常是这样一组路径:

browseAndAddCart -> login -> goCheckout -> submitOrderSuccess -> payOrder browseAndAddCart -> goCheckout -> submitOrderSuccess -> orderTimeoutCancel

第二条路径会产生一个“有趣”的动作序列:未登录状态下直接进入结算。适配层的守卫条件会在这里拦截下来:未登录用户不允许提交订单,于是这条路径的执行结果是“订单提交失败”,而这条失败路径被模型自动发现,正是我们想要的覆盖价值。如果人工设计用例,这类“未登录直接进结算”的场景大概率会被列进去,但仍需手工逐条写;有了模型,它是自动从状态拓扑里冒出来的。

4.3 执行与覆盖率分析

生成路径之后,我在适配层里把动作一一实现。每个方法内部调用真实的下单接口、支付接口,或在本地的模拟环境里跑。执行完成后,我用工具导出一份执行报告,看起来大概是这样的:

覆盖项覆盖率说明
状态覆盖100%6个状态全部走到
迁移覆盖83%6条边走了5条,登录失败分支未覆盖
路径组合2条执行,8条唯一生成器随机选了2条可执行路径

83%的迁移覆盖并不意外。一方面“login失败”这条边被单独拆到其他模型里;另一方面,随机策略在有限步数内本来就不会穷尽所有路径。这时候就需要做一件事:把未覆盖的边手工补测,还是调整生成策略重新跑。

我的经验是,第一步先看未覆盖的边是不是“值得测的边”。如果那条边代表的业务风险很低,比如日志级别的分支,直接标记为豁免即可,不用为了凑100%而不计代价。如果未覆盖的边是高风险的,比如“支付回调失败”,那就调整模型权重或单独为这条边设计一条起点到终点的路径。最怕的是抱着“覆盖率达到100%才算测完”的心态,把MBT当成覆盖率的数字游戏,那会做出很多没有实际价值的测试。

执行过程中我还会做一次静态核对:拿模型里的状态清单和需求文档里的状态枚举逐条比对,确认没有遗漏或命名不一致。这一步看起来多余,但对发现需求和实现不一致的问题非常高效。很多测试周期的需求漏洞,就是在这个核对环节被揪出来的。

5. 常见问题与排查技巧实录

5.1 高频问题排查速查表

用MBT时遇到的问题,来来回回就那么几类,我整理成一张速查表,方便遇到问题时对着排查。

问题现象根本原因解决思路
模型能建但生成用例数量巨大,根本跑不完覆盖准则过强或模型状态过细改用边覆盖,把低风险状态从模型中剪掉,增加动作权重约束
生成的路径中有大量“重复且无聊”的循环模型存在死循环路径,且守卫条件未限制在生成器中限制最大步数,或给循环边降低权重
模型总是生成不可达的路径边的守卫条件只在适配层判断,模型本身无法预判把部分高确定性守卫条件提升为模型内的“禁止转移”配置
用例执行频繁失败,且失败位置随机适配层断言过于前置,或执行环境时序不稳定把断言后置到路径终点;给异步操作增加显式等待
模型维护跟不上需求变更建模过程与需求评审脱节把模型评审纳入需求评审流程,需求变更带动模型变更
团队反馈模型看不懂、不敢改模型内容与代码结构耦合过深模型抽象回归业务层面,隐藏内部实现细节

除了这张表,我再提示一个被很多人忽视的点:模型文件的版本管理。模型和普通代码一样,会随着需求演进不断修改。务必把模型文件和生成结果快照一起纳入版本控制。这样,当某个测试周期出现奇怪的用例行为时,你可以回溯“这个用例是模型从哪个版本生成出来的”,排查效率能提升一个量级。

5.2 三条我踩过的坑

第一,建模时把接口权限关系和业务状态混在一起表达。比如把“管理员才可取消”和“订单已提交才可取消”写在同一层,造成守卫条件膨胀。后来我的处理方式是:权限类守卫下沉到适配层,由环境准备阶段保证当前用户具备对应权限;模型里只保留业务状态类守卫。这样一来,模型干净很多,生成的路径也不会被权限分支干扰。

第二,初始阶段过度追求“模型自动生成一切”。有些团队成员觉得上了MBT,用例、数据、预期结果都要自动产出。实际上,MBT最擅长的是“结构覆盖率高的路径生成”,数据准备和预期结果校验仍然需要测试工程师来定义。把预期不切实际地放到“全自动”的目标上,前期收益会被摊薄,团队容易丧失信心。合理预期是:让模型承担最枯燥、最容易遗漏的路径推演,人的精力集中到结果验证和边界补充上。

第三,忽略对生成路径的可读性处理。工具生成的路径名如果直接采用模型里边的名字,执行报告会变成一串很难懂的英文编码,评审时没人愿意看。我后来养成了一个习惯:在适配层里给每个动作加上一个中文业务描述。执行报告输出时同时带技术名和业务名。这一改动很小,但对跨团队协作的价值非常大,项目负责人能直接看懂测试覆盖的业务流,而不用费力做二次翻译。

这三条坑,都是文档里不会写、只有跑完一个完整迭代才会体会到的。分享出来,是想让后人少走一程弯路。MBT这套东西,能不能在一个团队里活下来,很多时候不是工具的问题,而是建模习惯和执行预期有没有摆正。把模型当成活的测试规格来养,它就会在每一个需求迭代里持续产生价值;如果当一次性交付物来建,那它很快会成为没人维护的负担。就我个人的体会而言,MBT最迷人的地方,不是生成用例节省了多少时间,而是它逼着你把业务行为想清楚后再动手测,这件事本身就是对测试质量最好的投资。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进