流程与状态:用结构化思维提升测试用例设计质量

39 阅读12分钟
  • 你是不是也遇到:需求一长就漏测、AI 一生成就幻觉、用例一多就根本评不动?
  • Treeify 专注把测试设计变成可建模、可评审、可持续迭代的过程——用结构化方法把问题空间拆开,再生成更少但更有覆盖的用例。
  • 想一起把测试设计做得更工程化,欢迎来共创/内测。
  • 添加 V:【TreeifyAI】进内测共创群,获得 Treeify 内测资格 / 免费 credits / MCP Server 试用

扫码_搜索联合传播样式-标准色版.png

 本篇文档面向希望提升测试设计能力的读者,特别是希望从“零散用例”走向“结构化、可追溯、高覆盖”的测试工程师。内容围绕两个核心方法:流程建模(MAE:主流程/替代流程/异常流程)状态模型(State Machine) 。这两者结合,可显著减少重复用例、降低遗漏风险,并为每个测试建立清晰、可验证的期待结果(测试 Oracle)。


一、为什么要同时使用流程与状态?

测试设计常见的两个痛点:

  • 用例堆叠,没有结构,新增功能后常常不知道是否需更新。
  • 边界、异常、重试、并发等场景难以覆盖,测试容易漏掉关键路径。
  • 期待结果模糊,测试结果容易出现争议或“测不准”。

流程(Flow)和状态(State)提供了一个简单而强大的解决框架:

  • 流程建模(MAE)
    描述“用户或系统如何完成任务”。帮助你找到主路径、替代路径、异常路径,快速找出测试覆盖的必要范围。
    更关键的是:MAE 会让你明确“哪些步骤是必经的”“哪些步骤是可插拔的”“哪些错误必须被设计出来”。很多重复用例本质上是“同一条主路径换了个输入”,MAE 能帮助你识别这种重复。

  • 状态模型
    描述“系统在任意时刻可能处于什么状态,以及哪些状态之间允许转换”。帮助你识别必须满足的条件(守卫)、不可破坏的约束(不变量)、可观察结果(Oracle)
    状态模型尤其擅长处理:

    • 同一个操作在不同状态下结果不同(例如 pending 时重复提交)
    • 同一请求被重试、并发触发、取消打断
    • 终态吸收(成功/失败/取消后不能再变)

当你能说清楚:
“当前处于什么状态、发生了什么事件、应该转到什么状态”,你就能写出**精准、可复现、可靠、不易碎(non-flaky)**的测试。


二、测试设计的通用工作流(7 步)

下面工作流可用于任何功能——登录、支付、退款、加购、优惠码、权限控制等。

  1. 明确目标与参与者
    例如:用户、系统自身、定时器、外部支付网关等。
    这里建议把“参与者”写得稍微具体一些:

    • 人:游客/会员/管理员
    • 系统:前端、后端服务、异步任务、定时器
    • 外部:支付网关、风控、短信、第三方验证
      这样后续在状态与可观测性上不会漏掉关键依赖。
  2. 编写 MAE 流程草稿
    按步骤列出:主流程、替代流程、异常流程。
    很多团队只写 Main,导致异常流程的覆盖完全依赖经验和临时补充。建议至少保证:

    • Main 一条
    • Alt 1–2 条(真实存在的可选路径)
    • Exception 2–4 条(更贴近事故来源)
  3. 从流程中提取“状态”
    只保留名词,例如:idlependingapplied,避免“verifying”这种动词状态。
    这一步的目标是把“过程”抽成“可枚举的系统阶段”。状态越清晰,用例就越不容易重复或遗漏。

  4. 定义状态转换
    使用统一格式:
    FROM --(事件/触发 [守卫条件])--> TO {动作}
    建议把“守卫条件”写成可测试的表达:输入约束、权限判断、版本号一致性、额度校验、重试次数等。

  5. 写出不变量与终止状态
    例如:退款成功后不可再修改;优惠金额不可为负。
    不变量不是“业务描述”,要尽量写成可断言的规则(可以落到字段、日志、事件、指标)。

  6. 补充可观测性
    每个转换要能在日志、指标或链路追踪里看到证据。
    这是减少“测不准”的关键:如果你无法观察到转换是否发生,那么这个转换就很难稳定被测试覆盖。

  7. 补充边缘情形
    包括重试、幂等、超时、并发、取消、版本冲突等。
    这些通常是线上事故的高发区。状态表里只要补几个关键边缘转换,就能用很少的用例覆盖高风险。

这一套流程能让你的测试能覆盖更多风险,并保持始终可维护。
更重要的是:当需求变更时,你能快速定位影响面——是影响某条流程步骤?某个状态的守卫条件?还是某个不变量?定位清楚之后,更新用例就不再靠“感觉”。


三、MAE 流程示例(优惠码应用)

下面是一个典型的优惠码流程,用于展示 MAE 的结构。

Main:     用户输入优惠码 → API 校验 → 展示已应用标识 → 总价更新
Alt:      会员在填写地址后再输入 → 再次校验 → 仍可应用
Exception: 使用过期优惠码 → 弹出提示 → 保留输入 → 总价不变
Exception: 不可叠加 → 提示冲突 → 引导用户移除冲突项

编写 MAE 流程时,建议每一步都是可观察行为:UI 更新、接口调用、日志键值等。
一个实用的小规则是:如果某一步无法观察,就把它拆成“可观察的输入”与“可观察的结果”。例如:

  • “API 校验”至少能观察到:调用发生、响应码与错误码、trace/span、关键日志字段。
  • “总价更新”至少能观察到:页面展示值变化、订单/购物车字段变化、或计算事件。

四、状态模型:最重要的那张表

“状态–事件–动作–下一状态”表(State Transition Table)是整个测试设计的单一可信源,所有测试均可从它生成。

FromEvent/TriggerGuard/ConditionAction/Side-effectsToObservables(可验证结果)
idleapply(code)len 1..16 && not expired标记为 pending;调用 /verifypendinglog: event=apply; trace 包含 verify
pendingverify.ok设置 applied=true;更新总价applied返回 200;UI 展示已应用;总价改变
pendingverify.fail(expired)保留输入;提示信息idle错误码 VALIDATION.code.expired
pendingtimeout + retry()attempts < Nbackoff + retrypendingmetric: retry_count++
pendingcancel()中断核验idlelog: cancelled=true

这张表能快速回答所有测试设计问题:
“什么输入能触发转换?”、“下一步应该去哪?”、“预期结果如何看见?”

补充两点实用建议(很多团队第一次建表会忽略):

  • To 状态要尽量稳定:例如失败后直接回 idle 还是进入 rejected,需要明确。否则你会出现“失败后有时回 idle、有时留 pending”的实际差异,导致用例争议。
  • Observables 要能支撑自动化:如果只是“页面提示错误”,后续自动化会变脆弱。更好的方式是:错误码/文案 ID/状态字段/日志 key 至少占一种。

五、用 ASCII 图绘制状态机(足够实用)

不需要 UML,只需要所有人能读懂即可。

 idle
  | apply(code) [valid]
  v
 pending -- verify.ok --> applied
   |  \
   |   -- verify.fail(expired) --> idle
   |
   -- timeout -> retry (<= N) -> pending

你会发现:用 ASCII 图的目的不是画得漂亮,而是把关键分支“可视化”。
很多遗漏用例在图上会特别明显,例如:

  • 缺少 cancel 分支
  • 缺少不可叠加的冲突分支
  • 缺少“重复 apply”的分支(幂等/去重)

六、不变量(必须可测试)

不变量是测试 Oracle 的最高优先级来源,定义了“系统永远不能破坏的规则”。

示例:

  • 总价永不为负。
  • 出现 applied 状态必须能在日志中找到成功校验记录。
  • 过期优惠码不会修改总价。
  • 重试次数 ≤ N;相同幂等键(Idempotency Key)请求结果一致。

每个不变量都应能转化为断言或监控规则。
建议你把不变量分成两类写,会更容易落到测试上:

  • 数据不变量:金额、状态、记录唯一性、版本号等
  • 行为不变量:幂等、重试上限、终态不可变、错误码一致性等

七、时间、重复操作与幂等性

状态模型最适合描述涉及时间的场景:

  • 幂等(Idempotency)
    相同请求(相同幂等键)在相同状态下返回同一结果。
    这里建议明确:“同一 key 的返回一致”指的是业务结果一致,而不只是 HTTP code 一样。最好明确返回体中的关键字段(例如 refund_id、status、applied=true)。

  • 重试(Retries)
    仅在可重试场景(如超时、429、5xx)发生重试,并实现退避(含随机抖动)。
    测试时不一定要验证每一个退避时间点,但至少要覆盖:

    • 可重试错误会重试
    • 不可重试错误不会重试
    • 重试次数上限生效(≤ N)
  • 超时(Timeout)
    需定义超时后状态保持不变还是直接失败。
    这个定义会直接决定你的测试断言:超时后 UI 是否提示、是否允许用户再次提交、系统是否会继续后台处理。

  • 截止时间(Deadline)
    超过某节点后禁止继续处理。
    典型例子:活动优惠码截止、订单支付截止、退款超时窗口。建议把 deadline 作为 Guard 写进状态表,否则很容易在变更后漏测。

这些都应列为状态转换表的一部分。


八、并发与取消

常见的风险与对应处理(应在测试中覆盖):

  • 并发输入优惠码:两次快速提交要么去重,要么拒绝第二次。
    测试至少要覆盖一条“并发触发”用例:两次点击、两次请求同时到达、或者同一幂等键重复提交。
  • 核验过程中取消操作:取消应回到 idle,且不影响总价。
    取消的“证据”最好是:状态回退、请求被中止或结果被忽略、日志中有 cancel 事件。
  • 与购物车更新的竞争:需使用版本号(如 cart_version)防止“丢失更新”。
    如果有版本号/ETag,这本身就是一个很好的 Guard:cart_version matches。测试可以覆盖“版本冲突返回特定错误码/提示刷新”。

每个并发风险都应包含“负面场景 + 恢复场景”。
负面场景回答“系统拒绝或去重是否正确”,恢复场景回答“用户是否能回到可操作状态”。


九、完整示例:退款流程(真实业务场景)

退款是典型“涉及钱与外部系统”的流程,特别适合状态建模。

状态图:
requestedprocessingsucceeded | failed | cancelled

FromEvent/TriggerGuardActionToObservables
requestedsubmit(refund, k)valid && amount≤charge && auth创建记录;入队;log 记录键 kprocessing返回 202;日志记录 processing
processinggateway.ok记录交易号succeededevent: refund.succeeded;log: txn_id
processinggateway.failretryable?backoff + retryprocessingmetric: retry_count
processingtimeoutattempts < N重试(同 key kprocessingidempotency_key 保持不变
processingcancel()未进入终态标记取消cancelledevent: refund.cancelled
processingduplicate submit(k)按 key 去重processing返回既有结果(幂等)
processinggateway.final_fail记录最终失败failed错误码;无重复扣款

不变量示例:

  • 每笔(charge + amount)不能出现两次退款。
  • 相同幂等键请求永远返回同一结果。
  • 终态(成功/失败/取消)不可再被修改。

压缩版测试建议(8–12 条即可覆盖核心逻辑):

  1. 正常退款 → processing → succeeded
  2. 超时 + 重试 → 单笔退款成功
  3. 幂等键重复提交 → 返回相同结果
  4. 重试多次后最终失败 → 无扣款
  5. 用户主动取消 → 状态变为 cancelled
  6. 成功后再访问 → 返回 success(幂等)
  7. 金额超过已扣金额 → 拒绝处理
  8. 外部服务返回 retryable 错误 → 重试并观察退避

(可选但高价值的补充两条,通常不超过 12 条也能覆盖更全面):
9. 并发 submit(同 key)→ 只产生一条退款记录

  1. cancel 与 gateway.ok 竞争 → 终态一致且可追溯(要么成功要么取消,但不能出现“两边都算”)

十、可观测性契约(Observability Contract)

为每个状态转换定义“证据”。测试结果必须通过这些证据验证:

  • 日志字段eventfrom_stateto_statecorrelation_ididempotency_keyerror_code
  • 指标:成功/失败/重试计数、延迟直方图
  • 链路追踪:每一步的 span 及属性
  • 证据来源:日志查询方式、监控看板、Trace 链接

建议在 PRD 或验收标准中明确这些字段,否则测试无从验证。
一个很实用的做法是:把“状态转换表”里的 Observables 列当成 PR 的验收项之一——只要关键转换不可观察,就视为实现未完成。


十一、常见反模式(务必避免)

  • 状态使用动词,如 “verifying”;应使用名词,如 pending
  • 隐式状态转换,例如在回调中修改状态却未记录。
  • 未加守卫条件,导致非法转换。
  • 转换不可观察(缺日志/指标/追踪)。
  • 错误处理策略不明确:有时重试,有时快速失败。
    这会导致测试无法稳定断言,也会导致线上体验不一致。

(补充一个常见但隐蔽的反模式,尤其在状态模型里高发):

  • 终态不吸收:成功/失败后又允许回到处理中或再次触发副作用,导致并发或重试下出现重复扣款/重复事件。

十二、评审检查清单(快速闸口)

  • MAE 流程覆盖主/替/异常路径
  • 状态均为名词,转换包含事件/守卫/动作
  • 不变量明确且可测试
  • 幂等性与重试规则定义清晰
  • 包含并发与取消场景
  • 可观测性契约完整
  • 终态不可变(吸收态)
  • 关联的用例/检查清单路径齐全

建议在评审时用这个清单做“快速扫一遍”的闸口。它的价值在于把讨论从“我觉得够不够”变成“模型是否完整、证据是否可验证”。


十三、状态表模板(建议每个功能都创建)

| From | Event | Guard | Action | To | Observables |
|------|-------|-------|--------|----|-------------|
|      |       |       |        |    |             |