抽象测试框架
自动化测试框架可以抽象出几种角色,任何测试框架都是
- test_data
- data_extractor
- test_executer
1. 抽象视角下的三种角色
先假设被测系统可以抽象成一个带上下文的函数:
SUT : (Input, Context) -> Output
在这个层面,我们完全不关心 HTTP 还是 gRPC,甚至不关心它是不是代码,只把它当作一个黑盒函数
1)test_data:测试意图的抽象表示
在抽象层面,test_data 不是文件,不是表格,而是测试意图的一个元素。在某个前置条件下,对 SUT 做某种操作,应该满足某种性质。也就是说,抽象的 test_data 至少携带三类语义(不是字段,是语义):
-
前置世界(上下文) :
- 系统处于怎样的业务状态(用户有多少余额、订单在什么阶段…)
-
操作/刺激:
- 我要对 SUT 施加什么操作(调用哪个能力,传入什么意图)
-
期望性质(不一定是具体值,可以是性质):
- 例如:余额减少但不为负数、列表长度增加 1、满足某个不变量
现在的test_data,本质就是对 SUT 行为的一条约束 / 场景描述,而不是任何具体的数据结构。
2)data_extractor:从抽象测试用例到可执行测试的映射
在这个高度,data_extractor 可以看成是一个纯函数:
data_extractor : TestSpecSpace -> ExecutableTestSpace
也就是说,它负责把人类视角 / 业务视角的测试意图,翻译成执行引擎能直接执行的测试程序。
在抽象层,它做的是语义层的编译,而不是单纯的解析文件。
你可以把它类比成:
- 编译器:从高级语言代码 → 中间表示(IR)
- 模型到模型的转换器:从业务场景模型 → 测试执行模型
它需要完成的抽象责任,比如:
-
把业务世界的概念映射到调用世界的概念
- 如:
用户=黄金会员 → 某个userId+ 获取 token 的方式 -
下单成功→ 一系列对 SUT 的调用序列(准备环境 + 下单)
- 如:
-
把性质翻译成可执行断言或检查步骤
-
如:
余额减少但不为负数 →- Step A: 调前查询余额
- Step B: 调用支付接口
- Step C: 再查余额 + 计算差值 + 判断结果
-
-
生成一个结构化的可执行测试脚本(仍可非常抽象,不等于具体语言),例如:
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 的抽象语义可以描述为:
- 按
Order 拓扑顺序执行每个 Step - 每执行完一个 Step,就把它的输出放入一个环境
Env中 - 后续 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. 把这一切再对回最开始提到的的三层角色
-
test_data (抽象测试数据)-
是
TestSpec空间里的一个元素:- 一个描述场景步骤 + 依赖 + 期望性质的抽象规格
-
和文件/格式无关,只是一个逻辑对象
-
-
data_extractor-
是一个映射:
TestSpec -> ExecutableTest -
负责:
- 把业务标签/角色绑定到具体值空间
- 把依赖图转为具体的执行步骤和参数绑定
- 把性质表达式转成可执行的 Checker
-
-
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