最小成本验证:垂直切片、测试替身与激励注入

验证预算永远不够花——台架机时要排队,CI 时长有上限,工程师的耐心更是稀缺资源。问题是:同样的预算,怎么买到最多的信心?这篇文章讲三招,正好串成一条流水线:

  1. 垂直切片回答策略问题——先验证什么?答案是最危险的假设;
  2. stub 与 fixture 回答手段问题——被测代码的依赖怎么脱钩、测试环境怎么保证干净;
  3. 激励注入回答执行问题——输入信号从哪个点、按什么时刻喂进去。

三招共用一条思想:花小钱,办大事。

一、垂直切片:先验证最危险的假设

切片(slice)= 从大系统里”切”一小块先做成的方法论。关键分清两种切法:

水平切(按层切):先做完整个数据层,再做整个逻辑层,再做整个 UI 层——风险是做到最后才发现层与层咬合不上,集成地狱全压在后半程。

垂直切(按功能切):一刀切穿所有层,做一个窄但完整的功能——不做”完整的后端 + 完整的前端”,先做”注册登录”这一个功能,从数据库到界面全通。

水平切:████████ 数据层    垂直切:█
        ████████ 逻辑层             █  注册登录
        ████████ UI 层              █ (全层贯通)
        (最后才能跑)               (第一周就能跑)

判断切得好不好,有三条铁律:

  1. 切穿所有层:少了任何一层就不是切片,是层的半成品;
  2. 端到端能跑:能独立演示、独立验收——”能跑”是切片的定义属性;
  3. 选最有风险的筋:切哪里不是随机的,专挑架构假设最可疑的那条缝切——切片的目的不是省工,是用最小成本验证最危险的假设。

拿域控制器 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

这个行业在两处用同一个词,都值得认出来:

  1. 单元测试的 stub:如上,替换函数/模块依赖。测 ECU 软件时把 CAN 收发、ADC 读取换成 stub,逻辑就能在 PC 上跑——这就是 SIL 能脱离硬件的前提;
  2. 残余总线仿真(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 分级复用。

使用纪律

  1. 别在用例间共享状态:static 成员、全局变量会破坏夹具的隔离承诺;
  2. SetUp 里只放所有用例都需要的准备;个别用例的特殊准备写进用例体,别让夹具臃肿;
  3. 重资源沉底到套件级:启动一次仿真器这类昂贵操作,用 SetUpTestSuite()(整套件一次),别在每个用例的 SetUp 里做;
  4. 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 就是一个坐在注入点上的激励源。

和相关概念的关系

工程上的三个关键问题

  1. 时序确定性:激励必须在意图的时刻注入,而不是”尽快”。虚拟时间模式下,第 500ms 的激励就必须落在第 500 个 tick——这是测试平台要做确定性调度和双模时钟的根本原因之一;
  2. 多通道同步:真实场景是多路激励并发的(CAN + 点云 + 诊断请求同时来),注入引擎要能在一个 tick 内按依赖顺序派发多路激励;
  3. 激励的可复现性:同一份激励定义跑两次,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),面向实时系统的发布/订阅中间件

下一步:

查看全部文章