抽象测试框架

自动化测试框架可以抽象出几种角色,任何测试框架都是

  • test_data
  • data_extractor
  • test_executer

1. 抽象视角下的三种角色

先假设被测系统可以抽象成一个带上下文的函数:

SUT : (Input, Context) -> Output

在这个层面,我们完全不关心 HTTP 还是 gRPC,甚至不关心它是不是代码,只把它当作一个黑盒函数

1)test_data:测试意图的抽象表示

在抽象层面,test_data 不是文件,不是表格,而是测试意图的一个元素。在某个前置条件下,对 SUT 做某种操作,应该满足某种性质。也就是说,抽象的 test_data 至少携带三类语义(不是字段,是语义):

  1. 前置世界(上下文)

    • 系统处于怎样的业务状态(用户有多少余额、订单在什么阶段…)
  2. 操作/刺激

    • 我要对 SUT 施加什么操作(调用哪个能力,传入什么意图)
  3. 期望性质(不一定是具体值,可以是性质):

    • 例如:余额减少但不为负数、列表长度增加 1、满足某个不变量

现在的test_data,本质就是对 SUT 行为的一条约束 / 场景描述,而不是任何具体的数据结构。


2)data_extractor:从抽象测试用例到可执行测试的映射

在这个高度,data_extractor 可以看成是一个​纯函数

data_extractor : TestSpecSpace -> ExecutableTestSpace

也就是说,它负责把人类视角 / 业务视角的测试意图,翻译成执行引擎能直接执行的测试程序。

在抽象层,它做的是语义层的编译,而不是单纯的解析文件。

你可以把它类比成:

  • 编译器:从高级语言代码 → 中间表示(IR)
  • 模型到模型的转换器:从业务场景模型 → 测试执行模型

它需要完成的抽象责任,比如:

  1. 把业务世界的概念映射到调用世界的概念

    • 如:用户=黄金会员​ → 某个 userId + 获取 token 的方式
    • 下单成功 → 一系列对 SUT 的调用序列(准备环境 + 下单)
  2. 把性质翻译成可执行断言或检查步骤

    • 如:
      余额减少但不为负数 →

      • Step A: 调前查询余额
      • Step B: 调用支付接口
      • Step C: 再查余额 + 计算差值 + 判断结果
  3. 生成一个​结构化的可执行测试脚本(仍可非常抽象,不等于具体语言),例如:

ExecutableTest =
  { pre_steps, invoke_steps, post_checks, expected_relations, … }

此时我们完全不关心这个 ExecutableTest 是 Python 对象、XML 文件还是别的什么,只关心它在语义上是一种​对 SUT 的操作与检查序列


3)test_executer:解释 / 运行可执行测试的语义

在这个高度上,​test_executer 是一个解释器

test_executer : ExecutableTestSpace × SUT -> TestResult

它的职责是:

给我一个可执行测试描述(ExecutableTest)和一套被测系统实现,我根据这个描述去驱动系统,并产生一个带有判定的结果。

这里的关键点:

  • test_executer ​只懂 ExecutableTest 这个中间表示的语义

    • 例如:pre_steps 代表前置操作序列
    • invoke_steps 代表主要调用
    • post_checks 代表检查
  • 它不需要也不应该理解业务含义,只负责:

    • 按顺序/规则执行这些步骤
    • 收集观察到的 Output / 中间状态
    • 交给检查逻辑(可以包含在 ExecutableTest 里,也可以进一步抽象成 oracle)来判断

换句话说,这个 test_executer 已经抽象成:

一种对测试中间语言的解释器 / 虚拟机。

这在概念上非常像:

  • Programming Language:

    • Source Code(test_data)
    • Compiler(data_extractor)
    • VM / Runtime(test_executer)

2. 把三层抽象到你完全不关心具体实现的样子

如果把刚才的东西压缩成纯概念图,就是:

           +------------------+
           |   TestSpecSpace  |   (抽象测试规格 / 测试意图)
           +---------+--------+
                     |
                     |  data_extractor : TestSpec -> ExecutableTest
                     v
           +-----------------------+
           | ExecutableTestSpace   |   (测试中间表示 / 测试脚本)
           +-----------+-----------+
                       |
                       |  test_executer : ExecutableTest × SUT -> TestResult
                       v
           +------------------+
           |   TestResult     |   (通过 / 未通过 / 不确定 + 观测信息)
           +------------------+

这个图里,你关心的只有​三个集合/空间两个映射​:

  • 集合(空间):

    • TestSpecSpace​:抽象 test_data 所在的世界
    • ExecutableTestSpace​:data_extractor 输出的可执行测试世界
    • TestResult:执行结果空间
  • 映射:

    • data_extractor:规格 → 可执行测试
    • test_executer:可执行测试 + SUT → 结果

1. 先搭一个极简的HTTP 领域模型

假设被测系统(SUT)里有三类 HTTP 操作(仍然是抽象的):

  • Login​:
    输入:(username, password)
    输出:token
  • CreateOrder​:
    输入:(token, sku_list)
    输出:order_id
  • PayOrder​:
    输入:(token, order_id)
    输出:payment_result

在模型层,你可以把每个接口抽象成一个​操作符

Login       : Cred -> Token
CreateOrder : Token × SkuList -> OrderId
PayOrder    : Token × OrderId -> PayResult

这些只是函数签名式的类型,没有任何协议细节。


2. 抽象层面的 test_data:一条场景规格

我们要做的测试场景是:

一个黄金会员成功登录,创建订单并支付成功。

这个时候,​test_data 不是任何文件,而是一个场景规格(ScenarioSpec) ​。
可以把它理解成一个带依赖的​步骤图 + 性质约束

2.1 把 test_data 看成一个有向图(抽象)

定义一个抽象的 TestSpec 类型:

TestSpec = (Steps, Edges, Properties)
  • Steps:一组抽象步骤节点,每个节点只是说明调用哪个操作符、扮演什么角色,不带具体值
  • Edges:步骤之间的依赖关系(谁的输出喂给谁)
  • Properties:对整个场景的期望性质(不是具体字段值,而是判断规则)

对我们这个 HTTP 场景,一个可能的 TestSpec 是:

Steps =
  L: Login( user_role = GOLD_MEMBER )
  C: CreateOrder( actor = L.user, items = {skuA, skuB} )
  P: PayOrder( actor = L.user, target_order = C.order )

Edges =
  L.token      -> C.token
  L.token      -> P.token
  C.order_id   -> P.order_id

Properties =
  P.payment_result.status == SUCCESS
  balance_after(L.user) = balance_before(L.user) - order_amount(C.order)

注意这里:

  • user_role = GOLD_MEMBER​ 这种东西是​业务语义,还不是具体 userId
  • skuA​、skuB 也可以只是抽象商品标签,不是具体 sku 编号
  • L.token​、C.order_id​ 是​符号变量,只是说P 这个步骤依赖 L、C 的输出

这就是抽象 test_data
一个图 + 约束的对象,描述了要怎么走场景、场景里数据是怎么流动的、以及期待满足什么性质。
完全不涉及实现细节。


3. 抽象层面的 data_extractor:规格 → 可执行测试

现在看 data_extractor

在模型层,它就是一个​函数

data_extractor : TestSpec -> ExecutableTest

我们再给 ExecutableTest 一个抽象定义,比如:

ExecutableTest = (ConcreteSteps, Order, Checkers)
  • ConcreteSteps​:带已绑定参数的步骤
  • Order:执行顺序 / 拓扑排序结果
  • Checkers:可执行的检查规则(可能还是抽象的表达式树)

对刚才的那个 TestSpec​,data_extractor 在抽象层大致做三件事:

3.1 语义绑定(从角色/标签到具体值空间的投影)

例如:

bind_user(GOLD_MEMBER) → some_user_id ∈ UserIdSpace
bind_sku(skuA)         → some_sku_code_A ∈ SkuSpace
bind_sku(skuB)         → some_sku_code_B ∈ SkuSpace

这一步的抽象含义是:

从业务标签空间映射到系统可接受的值空间。

我们完全不说这个映射怎么查,是表还是服务,只说它在数学上是一个映射函数。

3.2 把依赖图转成可执行步骤 + 变量绑定

得到的 ConcreteSteps 可能长这样(仍然抽象):

ConcreteSteps =
  L: Login( username = some_user_id, password = some_pwd )
  C: CreateOrder( token = L.token, sku_list = [sku_code_A, sku_code_B] )
  P: PayOrder( token = L.token, order_id = C.order_id )

此时你还是只知道:

  • 每个步骤要调用哪个操作符(Login / CreateOrder / PayOrder)
  • 每个实参从哪里来(常量、绑定结果、前序步骤输出)

3.3 把性质表达式翻译成可执行检查器

比如 Properties 中的性质:

P.payment_result.status == SUCCESS
balance_after(L.user) = balance_before(L.user) - order_amount(C.order)

data_extractor​ 翻译成某种可执行检查表达式树

Checkers =
  checker1: (P.payment_result.status) == SUCCESS
  checker2: (balance_after) == (balance_before - order_amount)

最终,data_extractor​ 输出一个纯抽象的 ExecutableTest

ExecutableTest =
  ConcreteSteps = {L, C, P}
  Order         = [L, C, P]  (来自 Edges 的拓扑排序)
  Checkers      = {checker1, checker2}

这里的关键点:

  • 依然没落到文件、框架,只是在说有一个中间表示(ExecutableTest)
  • ExecutableTest​ 是 test_executer 唯一需要理解的东西

4. 抽象层面的 test_executer:解释/执行 ExecutableTest

在模型层,test_executer 是一个解释器/运行时:

test_executer : ExecutableTest × SUT -> TestResult

我们给 TestResult 一个很抽象的定义:

TestResult = (Verdict, Observations)
  • Verdict ∈ { PASS, FAIL, INCONCLUSIVE }
  • Observations:执行过程中产生的所有可观测信息(响应、状态快照、时间等)

4.1 对依赖的抽象处理方式

面对 ExecutableTest​ 中的 Order​ + 引用(L.token​、C.order_id​),test_executer 的抽象语义可以描述为:

  1. Order拓扑顺序执行每个 Step
  2. 每执行完一个 Step,就把它的输出放入一个环境 Env
  3. 后续 Step 在执行前,从 Env 里按引用去取值:
Env₀ = ∅
(L_out, Env₁) = eval_step(L, Env₀, SUT)
(C_out, Env₂) = eval_step(C, Env₁, SUT)
(P_out, Env₃) = eval_step(P, Env₂, SUT)

这里的 eval_step 只是一个抽象函数:

eval_step : Step × Env × SUT -> (StepOutput, Env')

它的语义是:

在给定环境下解释这个步骤描述,去驱动 SUT,并把新产生的输出写回环境。

4.2 对性质检查的抽象处理方式

执行完所有 Step 后,test_executer​ 拿着 Env₃​ 和 Checkers 去做判断:

verdict = run_all_checkers(Checkers, Env₃)

其中每个 Checker 是一个​布尔表达式

Checker : Env -> Bool

所有 Checker 都为真 → PASS
有 Checker 为假 → FAIL
其他错误/不确定情况 → INCONCLUSIVE(比如依赖步骤执行失败)

最终得到一个抽象的 TestResult

TestResult =
  Verdict = PASS
  Observations = {
    (L, L_out),
    (C, C_out),
    (P, P_out),
    logs, metrics, ...
  }

5. 把这一切再对回最开始提到的的三层角色

  1. test_data(抽象测试数据)

    • TestSpec 空间里的一个元素:

      • 一个描述场景步骤 + 依赖 + 期望性质的抽象规格
    • 和文件/格式无关,只是一个逻辑对象

  2. data_extractor

    • 是一个映射:

      TestSpec -> ExecutableTest
      
    • 负责:

      • 把业务标签/角色绑定到具体值空间
      • 把依赖图转为具体的执行步骤和参数绑定
      • 把性质表达式转成可执行的 Checker
  3. test_executer

    • 是一个解释器:

      ExecutableTest × SUT -> TestResult
      
    • 负责:

      • 按依赖顺序执行步骤
      • 在环境里传播步骤之间的依赖数据
      • 执行 Checker 得出最终 Verdict


4. 这套框架的通用性边界在哪里?

从理论上说,这样设计的通用框架是可行的,而且足够抽象。但也有几个边界和取舍要提前想清楚:

4.1 TestSpec 的抽象度:抽太高 vs 抽太低

  • 抽太高

    • 所有东西都是抽象操作 + 标签,
    • Binder 会变得非常复杂,几乎相当于重写一套业务编排逻辑。
  • 抽太低

    • TestSpec 变成HTTP 请求描述清单,又容易退化成普通接口测试框架,失去抽象价值。

比较现实的做法

  • 按业务领域定义一套中等粒度的 DomainOperation

    • 比如电商:

      • Login
      • AddToCart
      • CreateOrder
      • PayOrder
      • CancelOrder
    • 而不是细到POST /api/order/create这种协议级别

这样:

  • TestSpec 足够贴近业务(可读、可复用)
  • Binder 负担可控
  • Executor 依然通用

从这种思路做测试框架有人做过吗?有人做过,而且不少。比如基于模型驱动测试(Model‑Based Testing, MBT)的 Spec Explorer、Robot Framework、测分布式系统一致性的Jepsen