接口测试场景编排:把 N 个请求串成一条能定位问题的链路

8 阅读9分钟

稍微复杂一点的业务,验证时都不是"调一个接口":注册、进件、下单、查单,后一步要用前一步返回的客户号;中间某个分支失败,还要知道到底断在哪。

这篇讲一个「场景编排」功能是怎么做出来的:用一张有向图描述执行顺序,后端按依赖调度,节点之间用变量传值。尽量不假设你熟悉图论或工作流引擎,先把概念讲清楚,再讲实现和坑。


0. 先说结论

要解决的问题做法
多个请求按顺序执行用「节点 + 连线」描述依赖,本质是一张有向无环图(DAG)
有分支、有并行连线可以带条件,节点可以标记为异步旁路
上一步的结果传给下一步用 ${节点ID:$.字段} 引用上游输出
某一步失败要能定位首个失败立即中止,未执行的节点标「跳过」,整体判失败
一条链路太长要复用支持「子场景」,像函数调用一样嵌套

如果你只想记一句话:场景编排 = 一张会按依赖自动往下走的流程图,节点之间靠变量传递数据。


1. 从一次联调说起

假设要验「下单」这条链路:

  1. 调进件接口,拿到 custNo;
  2. 用 custNo 调下单接口;
  3. 等两秒,调查单接口确认状态;
  4. 最后检查订单金额是不是 100。

只用单接口用例,会碰到三个很实际的问题。

数据要手抄。 第 1 步返回的 custNo 是一串数字,复制到第 2 步的参数里。抄错一位,下单会报"客户不存在",然后花半小时怀疑下游服务挂了。

顺序靠人记。 先跑哪个、后跑哪个、中间等多久,这些知识只在某个人的操作习惯里。换个人接手,就得重新问一遍。

失败现场留不住。 第 3 步失败了,但前两步的响应没有保存。只能去翻日志,还不一定翻得到。

场景编排就是把这三件事固化下来:顺序写进图里,取值写成引用,每一步的输入输出自动留档。


2. 三个基础概念:节点、边、DAG

2.1 节点和边

把每个请求看成一个节点,把"先做 A 再做 B"看成一条边。

[进件] ──► [下单] ──► [查单] ──► [断言金额]

这就是一张图。节点负责"做什么",边负责"先后关系"。

2.2 什么是有向无环图

  • 有向:边有方向,A → B 表示 A 先于 B;
  • 无环:沿着箭头走,永远回不到起点。

为什么必须无环?因为执行顺序要能排出来。如果 A 等 B、B 又等 A,那谁也没法先跑——程序会卡死,逻辑上也没有意义。

这种"有方向、没有环"的图,英文叫 DAG(Directed Acyclic Graph,有向无环图)。构建工具、任务调度、电子表格的公式依赖、Git 的提交历史,底层都是它。

2.3 拓扑排序:给节点排个序

有了 DAG,还需要回答"先跑谁"。拓扑排序就是找出一个线性顺序,保证每个节点都排在它的所有前驱之后。

最直观的算法叫 Kahn 算法,可以理解为「剥洋葱」:

  1. 找出所有没有前驱的节点(入度为 0),先执行;
  2. 执行完一个,就把它的出边删掉;
  3. 删完之后又出现新的入度为 0 的节点,继续;
  4. 如果最后还有节点没被剥掉,说明图里有环。

平台在保存场景时就会跑一遍这个检查,发现环直接拒绝保存。错误要在保存时暴露,不要等到执行时才发现。


3. 数据长什么样:一张图,不是一组步骤

场景存成一个 JSON,包含 nodes(节点)和 edges(边):

{
  "nodes": [
    {
      "id": "n1",
      "type": "httpCase",
      "data": { "label": "进件", "config": { "mode": "case", "caseId": 101 } }
    },
    {
      "id": "n2",
      "type": "dubbo",
      "data": {
        "label": "下单",
        "config": {
          "mode": "direct",
          "direct": {
            "interfaceName": "com.example.OrderService",
            "methodName": "create",
            "parameterTypes": ["java.lang.String", "java.math.BigDecimal"],
            "parameterValues": ["${n1:$.data.custNo}", 100]
          }
        }
      }
    }
  ],
  "edges": [
    { "source": "n1", "target": "n2", "data": { "condition": "input.httpStatus == 200" } }
  ]
}

节点类型是固定的几种:

类型作用
start / end起点/终点,可选,最多各一个
httpCase调 HTTP 接口
dubbo调 Dubbo 接口
mqCase发 MQ 消息并校验
uiCase跑一条 UI 回放用例
script执行一段 Groovy 脚本
subScene调用另一个场景

httpCase / mqCase / dubbo 都有两种模式:

  • case:复用已经保存的用例,改参数要回用例里改;
  • direct:参数直接写在节点里,适合这条链路专用的临时请求。

为什么不用「步骤数组」

用 steps: [A, B, C] 也能表达顺序,但表达不了分支和并行。真实链路里「风控通过才下单」「短信和邮件同时发」很常见。一旦要支持分支,数组方案就得发明 if、goto、parallel 之类的新语法,最后还是会变成一张图——不如一开始就用图。


4. 一次执行是怎么跑起来的

4.1 启动前:校验 + 编译

点击「运行」后,后端先做一次结构校验:

  1. 节点 id 不能为空、不能重复;
  2. 类型必须在白名单里;
  3. 每条边的两端必须存在,不能自己连自己;
  4. start 不能有上游,end 不能有下游;
  5. 用拓扑排序检查有没有环。

校验通过后,把 JSON「编译」成内存里的节点表,并提前算好每个节点的全部祖先集合。这一步是为了后面做引用检查:一个节点只能引用它的上游。

4.2 变量:运行前填写的参数

场景可以定义变量:

[
  { "name": "uid", "type": "string", "required": true },
  { "name": "amount", "type": "number", "defaultValue": 100 },
  { "name": "token", "type": "string", "secret": true }
]

启动时把用户填的值和默认值合并,按类型转成字符串/数字/布尔/JSON。标记为 secret 的变量只存在于本次执行的内存里,落库的运行记录写 ******。

4.3 调度:什么时候轮到某个节点

这是整个引擎最核心、也最容易被误解的地方。

一个节点要开始执行,需要同时满足两个条件:

  1. 所有「阻塞型前驱」都已经结束(成功、失败、跳过都算结束);
  2. 至少有一条入边被「激活」。

什么叫激活?如果边上没写条件,前驱成功就算激活;如果写了条件,就执行这段条件表达式,结果为 true 才算激活。

boolean hasBlocking = false;
boolean activated = false;
for (String p : node.predecessors) {
    NodeExecution pn = st.nodes.get(p);
    if (pn == null || pn.asyncNode) continue;      // 异步旁路不参与阻塞
    hasBlocking = true;
    if ("SUCCESS".equals(pn.status) && edgeActivated(st, p, node, pn)) {
        activated = true;
        break;                                      // 任意一条满足即可
    }
}
if (!hasBlocking || activated) startNode(st, node);
else finishNode(st, node, "SKIPPED", null, "前置条件未满足", null);

注意这里的语义:多个前驱之间是「或」,不是「与」。 只要有一条入边被激活,节点就会执行。

这样做的好处是表达灵活:多个分支汇合时,任意一条走通就能继续。代价是它不保证「A 和 B 都成功」。如果你的场景要求两个上游都完成,需要额外加一个汇聚节点,在节点里显式检查 outputs。

4.4 异步旁路:不挡路,但也不放过

节点可以标记 async: true,意思是「启动后立刻放行下游,不要等我」。

适合做什么?比如主流程继续跑,同时异步发一条通知、打一条埋点。下游不需要等它。

但要注意两件事:

  • 异步节点的结果不参与下游的激活判断;
  • 场景结束前仍然会等它收敛。它失败了,整个场景也会失败。

也就是说,async 只改变「是否阻塞下游」,不改变「失败是否传播」。

4.5 延迟与超时

  • 延迟:节点执行前等待一段时间,用于等异步落库、等缓存过期;
  • 超时:每个节点有硬超时,默认 120 秒,子场景默认 600 秒。

超时的实现是给节点任务加一个定时器,到点后把结果标成 FAILED,并调用 Future.cancel(true) 发中断信号。

这里有一个必须说清楚的边界:中断不等于强制停止。 如果节点正阻塞在 socket 读、数据库查询上,中断信号未必能让它立刻退出。所以节点结果会先落成失败,任务本身可能还会在后台跑一小会儿。节点终止入口做了幂等保护,后到的结果不会覆盖先到的终态。

4.6 延迟为什么要切片

long remain = Math.min(node.delayMs, MAX_DELAY_MS);
while (remain > 0) {
    if (stoppedOrAborted(st)) return false;
    long slice = Math.min(200L, remain);
    Thread.sleep(slice);
    remain -= slice;
}

如果直接 Thread.sleep(60_000),用户点停止后要等一分钟才有反应。切成 200ms 一片,每一片都检查一次停止标志,最坏 200ms 就能退出。


5. 数据怎么在节点之间流动

5.1 四种引用写法

写法含义
${inputs.uid}场景变量
${vars.uid}同上,给 Groovy 脚本习惯用
${n1:$.data.custNo}取 n1 节点输出里的 custNo
${nodes.n1.data.custNo}同上,简化写法

$.data.custNo 是 JSONPath,一种在 JSON 里定位字段的表达式。可以把它理解成「JSON 版的 XPath」:$ 表示根,.data 取字段,[0] 取数组第一个元素。

5.2 只允许引用上游

引用检查用的是编译阶段算好的祖先集合:

Set<String> ups = st.upstreams.getOrDefault(node.nodeId, Collections.emptySet());
if (!ups.contains(refId)) {
    throw new SceneNodeFailedException("节点 " + node.labelOrId() + " 引用了非上游节点: " + refId);
}

这条限制很重要。没有它,A 可以引用 B、B 也可以引用 A,运行时只能拿到 null,最后报一个和根因无关的错误。加上它之后,数据流也变成了一张 DAG,不存在循环依赖。

5.3 缺变量直接报错,不静默变空串

${inputs.uid} 在变量表里不存在时,直接抛「场景变量不存在」。

为什么不默认成空串?因为空串是一个"看起来合法"的参数。下游会带着空 uid 继续跑,最后报一个业务错误,排查的人根本想不到是变量名写错了。早失败、报得准,比"能跑完"重要。

5.4 标量和 JSON 的嵌入方式

替换结果不是无脑转字符串:

  • 字符串原样嵌入;
  • 数字、布尔直接转文本;
  • 对象、数组转成紧凑 JSON。

这样 ${n1:$.data} 放进请求体时得到的是合法 JSON,而不是 Java 的 {a=1} 风格字符串。


6. 断言和失败传播

6.1 什么是断言

断言就是「我期望这个值满足某个条件」。比如:

  • HTTP 状态码等于 200;
  • 返回 JSON 里 $.code 等于 0;
  • 数组长度等于 3;
  • 异常类型是某个业务异常。

每条断言执行后都会留下结果:期望值、实际值、是否通过、失败信息。

6.2 失败之后发生什么

节点执行完,先把断言结果挂到节点上,再统一检查:

node.result.setAssertions(resp.getAssertionResults());
node.result.setAssertionPassed(hasAssertions(...) ? resp.isAssertionPassed() : null);
checkAssertions(resp.getAssertionResults(), resp.isAssertionPassed());

只要有一条断言没过,就抛异常,节点标记为 FAILED。然后:

  1. 第一个失败的节点抢占「中止权」;
  2. 所有还在 PENDING 的节点立即标 SKIPPED;
  3. 已经在跑的节点不强行杀,等它自然结束;
  4. 所有节点结束后,统一计算整次运行的状态。

6.3 为什么「跳过」也算失败

如果只是某个分支条件没满足,节点被跳过,看起来不算错。但整次运行仍然判 FAILED,原因是:你无法区分「设计上就该跳过」和「因为上游挂了所以没跑」。

把跳过算失败,可以保证"绿色"只代表一种含义:所有节点都按预期执行并通过了断言。宁可多一次人工确认,也不要让一条链路绿得可疑。

6.4 为什么失败原因要延迟写

失败原因先写进内部字段 failureError,等所有节点都终止后才落到对外的 error。

因为前端是轮询的。如果提前把运行状态置成 FAILED,前端会在"还有节点在跑"的中间态就停止刷新,用户看到的是一个不完整的结果页。


7. 子场景:把一条链路当成一个节点

subScene 节点允许调用另一个场景。可以把它理解成编程里的函数调用:

  • 父场景传变量进去;
  • 子场景完整执行自己的图;
  • 子场景结束后,把输出返回给父节点;
  • 父节点根据成功/失败决定自己是否失败。

几个约束:

  • 同步:父节点会等子场景跑完;
  • 最多 3 层:防止无限嵌套;
  • 调用链检测循环:A 调 B、B 又调 A,保存时就报错;
  • 变量是副本:子场景里修改 vars,父场景看不到,需要的数据通过输出显式返回;
  • 不单独落运行记录:子场景属于父节点的一次执行,避免运行记录爆炸。

还有一个实现上的细节:子场景用独立的线程池。父节点占着主线程池的线程等子场景,如果子场景也用同一个池,几层嵌套就能把池子占满,互相等待到死锁。


8. 运行状态、停止和持久化

8.1 运行中读内存,结束后读数据库

执行中的状态放在内存的 ConcurrentHashMap 里,查询直接读内存;执行结束后写进数据库,查询读快照。

为什么不一直读数据库?因为节点状态变化很频繁,每个节点开始、结束、断言结果都要更新,实时写库既慢又没必要。运行结束后做一次完整快照就够。

8.2 停止是「协作式」的

用户点停止后,引擎不会强行杀掉线程,而是:

  • 置一个停止标志;
  • 所有还没开始的节点标 SKIPPED;
  • 已经在跑的节点继续跑完;
  • 延迟中的节点每 200ms 检查一次,尽快退出。

为什么不强杀?因为一个正在写数据库的节点被突然中断,可能留下半条数据。当前的选择是「不制造脏数据优先」,长耗时节点靠超时兜底。

8.3 增量落库只写终态

每有一个节点结束,就把结果写进数据库,但只写已经到达终态的节点。PENDING 和 RUNNING 不落库,避免把中间态当成结果。


9. 常见误区

  • 以为多前驱是「并且」。 默认是「或」,需要全部成功就自己加汇聚节点。
  • 以为 async 失败不影响主流程。 它不阻塞下游,但失败仍会让整个场景失败。
  • 以为边条件写错会报错。 条件抛异常、返回非布尔值,一律按「未激活」处理,会静默走另一条分支。
  • 以为超时一定能停住任务。 它只是发中断信号,阻塞 IO 未必响应。
  • 以为停止会中断正在跑的节点。 不会,只是不再启动新节点。
  • 以为可以引用任意节点的结果。 只能引用祖先节点,兄弟分支要先汇聚。
  • 以为子场景改的变量会回传。 不会,变量是副本。

10. 小结

场景编排看起来是"画流程图",真正需要设计清楚的是四件事:

  1. 顺序:用 DAG 表达依赖,保存时用拓扑排序把环挡掉;
  2. 取值:用 ${节点:$.字段} 引用上游输出,用祖先集合阻止前向引用;
  3. 失败:首个失败即中止,跳过也算失败,结果要能定位到具体节点和断言;
  4. 生命周期:延迟切片、超时中断、协作式停止、终态增量落库。

这四件事只要有一样含糊,联调现场就会变成猜谜。工具的价值不在于能画多漂亮的图,而在于同一条链路,换个人、换个时间跑,结果和失败原因都是一样的。