最小成本验证:垂直切片、测试替身与激励注入
验证预算永远不够花——台架机时要排队,CI 时长有上限,工程师的耐心更是稀缺资源。问题是:同样的预算,怎么买到最多的信心?这篇文章讲三招,正好串成一条流水线:
- 垂直切片回答策略问题——先验证什么?答案是最危险的假设;
- stub 与 fixture 回答手段问题——被测代码的依赖怎么脱钩、测试环境怎么保证干净;
- 激励注入回答执行问题——输入信号从哪个点、按什么时刻喂进去。
三招共用一条思想:花小钱,办大事。
一、垂直切片:先验证最危险的假设
切片(slice)= 从大系统里”切”一小块先做成的方法论。关键分清两种切法:
水平切(按层切):先做完整个数据层,再做整个逻辑层,再做整个 UI 层——风险是做到最后才发现层与层咬合不上,集成地狱全压在后半程。
垂直切(按功能切):一刀切穿所有层,做一个窄但完整的功能——不做”完整的后端 + 完整的前端”,先做”注册登录”这一个功能,从数据库到界面全通。
水平切:████████ 数据层 垂直切:█
████████ 逻辑层 █ 注册登录
████████ UI 层 █ (全层贯通)
(最后才能跑) (第一周就能跑)
判断切得好不好,有三条铁律:
- 切穿所有层:少了任何一层就不是切片,是层的半成品;
- 端到端能跑:能独立演示、独立验收——”能跑”是切片的定义属性;
- 选最有风险的筋:切哪里不是随机的,专挑架构假设最可疑的那条缝切——切片的目的不是省工,是用最小成本验证最危险的假设。
拿域控制器 SIL 平台举例。第一刀切哪?不是最简单的功能,而是 AEB 闭环:时钟→调度→总线→SUT→判定→报告,一层不少。选它不是因为它简单,恰恰相反——时基抽象、总线抽象、测量链路、SUT 生命周期、用例判定,五个高风险架构假设全在这条链上。反过来,那些纯工作量的部分(更全的报文格式、更多的总线协议支持)风险低,往后排。闭环一旦端到端跑通,正例、反例、确定性三条验收线全绿,架构就算赌对了。
这就是切片思想的精髓:SIL 闭环全绿,说明架构赌对了,再投硬件的钱;如果 SIL 都跑不顺,损失的只是十几个人周,不是一套台架。 切片就是买一份”最坏情况下损失最小”的期权。
一个近亲概念叫行走骨架(walking skeleton,Alistair Cockburn 提出):先把系统的最小骨架立起来走两步,再往上长肉。
二、Stub:软件里的假负载
切片定了”先做什么”,接下来的问题是:切片里的被测代码依赖一堆还没影的模块和硬件,怎么让它先跑起来?答案是测试替身(test double),最常用的一种叫 stub(桩):一个预先录好固定应答的”假零件”,在测试时替换掉真实的依赖模块。
最贴切的类比是硬件里的假负载(dummy load):测电源模块时,你不会接一台真设备上去,而是接一个电阻箱——它不是真负载,但能让电源”以为”自己在带载,从而暴露电源本身的问题。stub 就是软件里的假负载:被测代码不知道对面是假的。
// 真实依赖:读真实车速传感器(测试环境里没有这硬件)
class ISpeedSensor { public: virtual float speed_kmh() = 0; };
// Stub:测试用假传感器,返回预先设定的值
class StubSpeedSensor : public ISpeedSensor {
public:
explicit StubSpeedSensor(float v) : v_{v} {}
float speed_kmh() override { return v_; } // 罐头答案:问啥都答 60
private:
float v_;
};
// 测试:给 AEB 逻辑接上假传感器,验证 60 km/h 时的制动决策
TEST(AebTest, brakes_at_60kmh) {
StubSpeedSensor stub{60.0f}; // ← stub 顶替真实硬件
AebLogic aeb{stub};
EXPECT_TRUE(aeb.should_brake());
}
三个特征:实现极简(几行)、行为可预测(返回固定值)、只服务于测试(不进生产代码)。
替身家族:别和 mock 混了
测试替身有五种,行业里经常混用,精确区分是:
| 替身 | 干什么 | 关心什么 |
|---|---|---|
| Dummy | 只用来凑参数列表,从不被调用 | 什么都不关心 |
| Stub | 预设固定应答(”问就答 60”) | 状态:让被测代码走到某条路径 |
| Fake | 能工作的简化版(内存数据库顶替真数据库) | 功能能跑,但简化 |
| Spy | 记录调用情况的窃听器 | 交互:被调了几次、参数是什么 |
| Mock | 预设”应该被怎么调用”,不符就失败 | 交互的预期(最强,最脆) |
一句话口诀:stub 管”答”(canned answers),mock 管”问”(expectations)——stub 验证状态(”给了 60,它刹车了吗”),mock 验证行为(”它有没有调用刹车函数、参数对不对”)。
嵌入式语境里的两种 stub
这个行业在两处用同一个词,都值得认出来:
- 单元测试的 stub:如上,替换函数/模块依赖。测 ECU 软件时把 CAN 收发、ADC 读取换成 stub,逻辑就能在 PC 上跑——这就是 SIL 能脱离硬件的前提;
- 残余总线仿真(restbus simulation):CANoe 里仿真”总线上其他 20 个节点”的那块,本质就是一整车 stub——每个仿真节点按 DBC 定时发预设报文。测单个域控时,整车网络就是一个大 stub。
第二种思路再往前进一格,就是 stub SUT:一个最小行为模型的假被测对象——接收注入的总线/OSI 数据,按预设逻辑回包(比如 UDS 0x10/0x22/0x27 应答)。因为是全软件可控的”假的”,可以随手让它出故障:回错 NRC、延迟 500ms 应答、发畸形 CAN 帧——stub 由此兼任故障注入器。它的用途不是测自己,而是在真实被测对象到位之前,先把测试平台跑通——行走骨架的”骷髅”,就是靠 stub 站起来的。
一句话总结 stub:它是”可控的假象”——测试要测的是 A,就把 A 的邻居全部换成 stub,让 A 在无菌环境里暴露本性。
三、Fixture:卡进台架,从同一条起跑线出发
stub 解决了”依赖是假的”,还剩一个问题:”每次测试的环境是一样的吗?”这就是夹具(fixture)的职责。这个词是从硬件测试借来的:产线上测一块 ECU 电路板,要把它卡进一个工装夹具里,探针压住测试点、供上电、接好负载,测完松开换下一块。夹具保证的是:给每件被测品提供一个完全一致、可重复的测试环境。
软件测试借用了同一个概念:fixture = 一组测试共享的、每次开始前搭建、结束后拆除的固定环境。Google Test 里的样子:
class AebTest : public ::testing::Test { // 夹具类:公共环境的定义
protected:
void SetUp() override { // 每个用例开跑前自动调用:搭建环境
sensor_ = new StubSpeedSensor{60.0f};
aeb_ = new AebLogic{*sensor_};
}
void TearDown() override { // 每个用例结束后自动调用:拆除清理
delete aeb_;
delete sensor_;
}
StubSpeedSensor* sensor_; // 夹具成员:所有用例共享的对象
AebLogic* aeb_;
};
TEST_F(AebTest, brakes_at_60kmh) { // TEST_F:用这个夹具跑
EXPECT_TRUE(aeb_->should_brake());
}
TEST_F(AebTest, no_brake_when_clear) {
StubSpeedSensor slow{0.0f}; // 用例体内换输入:换一个 0 km/h 的桩
AebLogic aeb2{slow};
EXPECT_FALSE(aeb2.should_brake());
}
关键机制(很多人用了一年 GTest 都没意识到):每个 TEST_F 都会创建一个全新的夹具对象,跑完 SetUp → 测试体 → TearDown 就销毁。所以两个用例之间零状态残留——上一个用例改坏了 aeb_ 的内部状态,下一个用例拿到的也是干净的。这就是夹具的核心价值:用例之间的隔离性。
夹具 vs stub:管的事不同
这俩经常一起出现,但管的事不同:
| Stub(桩) | Fixture(夹具) | |
|---|---|---|
| 替换什么 | 被测对象的依赖(假传感器、假总线) | 测试本身的准备工作(搭对象、喂初值、清理) |
| 类比 | 假负载 | 工装台架本身 |
| 关系 | 夹具里常常装着 stub | 夹具是容器,stub 是里面的一件道具 |
上面代码里 StubSpeedSensor 是桩,整个 AebTest 类是夹具——夹具负责”把被测物卡进台架”,桩负责”扮演台架上的假零件”。
顺带一提,软件测试的词汇几乎全是硬件测试借来的:fixture ← 工装夹具/台架(HIL 台架本质上就是一个物理 fixture:固定供电、固定线束、固定负载箱);stub ← 假负载;harness(测试线束/测试框架)← 线束;golden sample(黄金样本)← 计量里的标准件。pytest 的 fixture 概念同源但机制更花哨:依赖注入式,可以按 session/module/function 分级复用。
使用纪律
- 别在用例间共享状态:static 成员、全局变量会破坏夹具的隔离承诺;
- SetUp 里只放所有用例都需要的准备;个别用例的特殊准备写进用例体,别让夹具臃肿;
- 重资源沉底到套件级:启动一次仿真器这类昂贵操作,用
SetUpTestSuite()(整套件一次),别在每个用例的 SetUp 里做; - TearDown 必须对称:申请了的资源必须还——嵌入式里忘记复位硬件状态的夹具,会造成”用例顺序不同结果不同”的灵异 bug。
一句话总结:夹具保证”每次测试都从同一条起跑线出发”——测试的可信度,一半在被测代码,一半在夹具是否干净。
四、激励注入:测试平台的手
切片定了范围,stub 和夹具搭好了环境,最后一步:让被测对象”演”出你要的场景。激励注入(stimulus injection)是测试领域的术语,尤其在嵌入式和 XIL 测试中常见:
激励(stimulus)= 喂给被测对象的输入信号;注入(injection)= 在指定的时间点、从指定的入口,把这个信号”打”进被测系统,替代或驱动它原本的真实输入。
SUT 不会自己演戏——它是一条输入→处理→输出的流水线。你想让它表现出某种行为,就得从上游把对应的输入”注入”进去,然后观察它的输出是否符合预期。测域控制器的 AEB 功能:
时刻 t=0ms: 注入 CAN 帧 → 前方目标距离 50m,相对速度 -10m/s
时刻 t=1000ms: 注入更新帧 → 距离 30m(目标在逼近)
时刻 t=2000ms: 距离 15m → 观察:SUT 是否发出了制动请求?
这里的每一帧都是一次激励注入。测试平台里的 CSV 回放功能,本质上就是离线编排好的激励注入序列——CSV 定义激励,回放引擎按主时钟的节拍逐条注入。
注入点的选择
从远离 SUT 到贴近 SUT,注入点分几层:
| 层级 | 注入什么 | 例子 | 真实度 |
|---|---|---|---|
| 物理/电气层 | 电压、电流、电阻、PWM | HIL 台架上模拟轮速传感器信号 | 最高(需要真硬件) |
| 总线层 | CAN/CAN FD/车载以太网帧 | SocketCAN 发 0x123 帧 | 高 |
| 传感器数据层 | 原始点云、图像帧、IMU 数据 | 回放激光雷达点云包 | 中高 |
| 软件接口层 | DDS Topic、API 调用、函数入参 | mock 掉定位模块的返回值 | 中(速度快、可控性强) |
原则:注入点越靠近 SUT 的真实输入边界,测试越真实;越靠近内部,注入越便宜,但越容易”测的是假人”。这和上一节 stub 说的是同一个问题——stub 就是一个坐在注入点上的激励源。
和相关概念的关系
- 激励注入 vs 故障注入(fault injection):同一套注入机制,两种用途。激励注入喂的是正常输入(驱动 SUT 走完一个场景);故障注入喂的是异常输入(丢帧、CRC 错误、超时、野值),考验鲁棒性。前面说的 stub SUT 故障注入器,就是把后者做成了可编程的;
- 激励注入 vs 观测(observation):注入和观测是一对——只注入不观测,就不知道 SUT 接招后的反应。用例描述负责注入编排,判定引擎负责观测判定,时钟模块保证两者在同一个时间轴上对齐。
工程上的三个关键问题
- 时序确定性:激励必须在意图的时刻注入,而不是”尽快”。虚拟时间模式下,第 500ms 的激励就必须落在第 500 个 tick——这是测试平台要做确定性调度和双模时钟的根本原因之一;
- 多通道同步:真实场景是多路激励并发的(CAN + 点云 + 诊断请求同时来),注入引擎要能在一个 tick 内按依赖顺序派发多路激励;
- 激励的可复现性:同一份激励定义跑两次,SUT 看到的比特流必须完全一样——否则判定结果不可信。
一句话总结:激励注入就是测试平台的”手”——场景是被测系统演出来的,而让它演戏的方式,就是从选好的注入点、按精确的时刻,把输入喂进去。
回看整条线:切片定方向——先验最危险的假设;stub 和夹具做脱钩——依赖换成可控的假象,环境保证每次干净;激励注入做执行——输入从选好的点、按精确的时刻喂进去。 三招服务于同一个目标:真金白银的硬件投入之前,用最小的成本把最危险的假设验掉。信心不一定贵,关键是花在哪。
附:名词解释(按出场顺序)
| 名词 | 通俗解释 |
|---|---|
| 垂直切片(vertical slice) | 切穿所有架构层的最小端到端功能,用来最早验证最危险的假设 |
| AEB | 自动紧急制动(Autonomous Emergency Braking),ADAS 的典型安全功能,本文的例子场景 |
| SUT | System Under Test,被测对象/被测系统 |
| SIL | 软件在环(Software-in-the-Loop):控制器软件在 PC/服务器上全仿真运行 |
| 行走骨架(walking skeleton) | Alistair Cockburn 提出的概念:先把系统最小骨架立起来跑通,再往上长肉;切片的近亲 |
| 测试替身(test double) | 测试时替换真实依赖的假对象的统称 |
| stub(桩) | 预设固定应答的测试替身,顶替真实依赖 |
| 假负载(dummy load) | stub 的硬件原型:测电源时顶替真实负载的电阻箱 |
| Dummy / Fake / Spy / Mock | 替身家族的另外四种:凑参数的、能跑的简化版、记录调用的、预设交互预期的 |
| 残余总线仿真(restbus simulation) | 仿真”总线上其他所有节点”,让单个 ECU 以为自己在真实整车网络上 |
| CANoe | 行业常用的总线仿真与测试工具 |
| DBC | CAN 数据库文件,定义各节点收发的报文与信号 |
| UDS | 统一诊断服务(ISO 14229);0x10 切会话、0x22 读数据、0x27 安全解锁都是它的服务 ID |
| NRC | 否定响应码(Negative Response Code):诊断服务拒绝请求时返回的原因码 |
| 故障注入(fault injection) | 喂异常输入(丢帧、CRC 错误、超时、野值)考验鲁棒性 |
| fixture(夹具) | 一组测试共享的固定环境:每个用例前搭建、后拆除;借自硬件工装夹具 |
| GTest(Google Test) | Google 的 C++ 测试框架;它的 TEST_F + SetUp/TearDown 是夹具的标准形态 |
| HIL | 硬件在环(Hardware-in-the-Loop):真实 ECU 接仿真台架 |
| harness / golden sample | 同样借自硬件测试的词:测试线束/测试框架;计量里的标准件(黄金样本) |
| pytest | Python 测试框架,其 fixture 概念同源、机制更花哨(按 session/module/function 分级复用) |
| 激励注入(stimulus injection) | 在指定时刻、从指定入口把输入信号”打”进被测系统 |
| XIL | X-in-the-Loop,”X 在环”:MIL/SIL/PIL/HIL 这类闭环测试环境的统称 |
| SocketCAN | Linux 内核的 CAN 协议栈接口 |
| DDS | 数据分发服务(Data Distribution Service),面向实时系统的发布/订阅中间件 |
下一步:
查看全部文章