从Mock到真实组件:一次测试框架架构演进的实践记录

从Mock到真实组件:一次测试框架架构演进的实践记录

叫我EC就好

痛点:当Mock开始变成负担

某个项目中遇到了这样一个问题:在进行软件在环测试时,团队习惯在单独的测试项目中使用简化的mock来模拟其他设备组件(如WheelBase、Pedal、换挡器等外设)。随着测试需求的复杂化,这种简化mock的方式开始暴露出明显的局限性。

项目刚开始时,为了快速推进测试,我们对所有外设组件(WheelBase、Pedal、换挡器这些)都用了轻量级的Mock。当时觉得这个思路很完美:快速、简单、可控。每个Mock就是几十行代码,实现基本的接口响应,看起来很干净。

但随着项目深入,我发现自己陷入了一个”Mock地狱”——每次产品需求稍微复杂一点,就得去改Mock;真实组件一更新接口,又得同步更新Mock。更要命的是,测试过了,到了真实环境就各种奇怪问题。

有次为了模拟一个踏板的复杂响应曲线,模拟框架写了200多行Mock代码,写完后突然意识到:这TM不就是在重新实现一遍真实组件吗?而且还是个简化版的!

我们是如何一步步走进坑里的

回头看,Mock方式的问题其实早有征兆,只是当时没在意:

第一个警报:Mock开始”长肉”

最初的Mock确实很简洁,但慢慢地,每个Mock都开始”长肉”。为了支持新的测试场景,不断往里面加逻辑。我记得有个换挡器的Mock,从最初的30行代码膨胀到了500多行,里面各种if-else,看着就头疼。

更糟糕的是,每次真实组件更新,都要问自己:这个变更Mock要不要同步?要同步到什么程度?经常是猜着来,结果就是Mock和真实组件渐行渐远。

第二个警报:测试通过≠功能正确

这是最让人崩溃的地方。测试环境里跑得好好的功能,到了实际环境就各种问题。原因很简单:Mock太”乖”了,它永远按照你期望的方式响应,但真实组件有自己的”脾气”。

比如说,真实的踏板组件在温度变化时响应时间会有微妙的差异,但Mock里这些细节都被抽象掉了。结果就是,一些基于时序的边界条件在Mock环境下永远不会出现。

第三个警报:两套代码的维护噩梦

最要命的是维护成本。团队里有人专门负责真实组件,有人负责测试和Mock。两边的理解经常对不上,Mock的行为和真实组件慢慢产生分歧。

有一次,真实组件修复了一个边界条件的bug,但忘了同步给测试开发团队。结果我们的Mock还在”正确地”重现这个bug,浪费了好几天时间才发现问题所在。

痛定思痛:重新设计的思路

在连续踩了几个坑之后,我们开始反思:能不能直接用真实组件来测试?

最初的想法很简单

既然Mock维护这么麻烦,为什么不直接用真实组件?但问题来了:

第一个问题:真实组件都是独立的程序,如何在测试环境里协调它们?
第二个问题:测试需要精确的时序控制,如何让多个进程”步调一致”?
第三个问题:如何保证测试的可重复性和稳定性?

经过几轮头脑风暴,我们提出了一个”中央协调器”的思路:

核心想法:真实组件 + 进程隔离 + 统一协调

为什么选择多进程?

最开始考虑过多线程,但很快发现问题:真实组件往往有各自的初始化流程、资源依赖,强行塞到一个进程里容易互相干扰。而且实际产品中各ECU本来就是独立的硬件单元,用独立进程来模拟更接近真实情况。

为什么需要中央协调器?

虽然各组件独立运行,但测试需要精确的时序控制。想象一下,如果让各个进程自由发挥,测试结果就完全不可控了。所以需要一个”指挥官”来协调大家的节奏。

最终的架构长这样

经过反复讨论和原型验证,我们设计了这样一个架构:

ProcessManager - 中央协调器

这家伙就是”总指挥”,负责协调所有进程的节奏。它不参与具体的测试逻辑,专门管同步:什么时候开始新的测试用例,什么时候进入下一个时钟周期,什么时候结束测试。

主测试进程 - 测试执行者

这个就是我们原来的GTest进程,负责跑具体的测试逻辑。但现在它不是单打独斗了,每执行一步都要和ProcessManager确认:”兄弟们都准备好了吗?”

辅助进程 - 真实组件们

每个真实组件跑在自己的进程里,专心做自己的事情。但它们也要听ProcessManager的指挥,该同步的时候同步,该停的时候停。

听起来很简单,但实现起来有不少坑要踩…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 中央协调器核心实现
class ProcessManager {
public:
ProcessManager(int max_process_count) : max_process_count_(max_process_count) {}

void run() {
setupSharedMemory();

while (!shutdown_requested_) {
// 检查进程心跳和状态
checkProcessHealth();

// 处理用例间握手
handleCaseTransition();

// 处理用例内锁步同步
handleTickSynchronization();

std::this_thread::sleep_for(std::chrono::milliseconds(1));
}

cleanup();
}

private:
void handleCaseTransition() {
if (allProcessesReadyForCase()) {
shared_data_->case_generation++;
shared_data_->case_cond.notify_all();
}
}

void handleTickSynchronization() {
if (allProcessesReadyForTick()) {
shared_data_->tick_generation++;
shared_data_->tick_cond.notify_all();
resetTickReadyFlags();
}
}

bool allProcessesReadyForCase() {
int ready_count = 0;
for (int i = 0; i < max_process_count_; i++) {
if (shared_data_->process_slots[i].active &&
shared_data_->process_slots[i].ready_for_case) {
ready_count++;
}
}
return ready_count == getActiveProcessCount();
}
};

// 进程通信接口
class IpcAdapter {
public:
bool initialize(const std::string& process_name);
bool waitForNextTick(); // 等待时钟同步信号
bool startNewCase(uint64_t ticks); // 测试用例启动协调
void shutdown();
private:
SharedSyncData* shared_data_; // 共享内存区域
int process_slot_; // 进程标识符
};

实际运行是这样的

整个框架的工作流程,用大白话说就是:

第一步:大家各就各位

1
2
3
4
5
# 先启动总指挥
./pm_simple 2 # ProcessManager上线,准备管2个进程
# 然后各进程依次上线
./wheelbase_test # 主测试进程启动
./pedal_test 1 # 踏板组件进程启动

第二步:测试用例开始时的”握手”

每次开始新的测试用例,就像开会前确认人都到齐了:

  • 主测试进程:”我要开始新用例了”
  • 各组件进程:”我准备好了”
  • ProcessManager:”好,大家一起开始!”

这个握手很重要,确保所有进程都在同一起跑线上。

第三步:用例执行时的”锁步”同步

这是最精细的部分。每个时钟周期,所有进程都要报告:”我这一步做完了”,然后ProcessManager说:”好,大家一起进入下一步”。

就像军训时的”齐步走”,必须步调一致。如果有进程慢了,其他进程就等着,确保时序绝对同步。

第四步:测试结束时的优雅退出

主测试进程:”我的任务完成了,下班!”
ProcessManager:”收到,通知其他进程也下班”
各组件进程:”明白,我也走了”

这种”各司其职”的设计让每个进程的职责很清晰,出问题时也容易定位。

共享内存:进程间通信的”纽带”

为什么选择共享内存?

一开始我们也纠结过用什么方式让进程间通信。试过几种方案:

消息队列:用过一段时间,但发现每次通信都要拷贝数据,高频同步时延迟明显。

Socket:写起来简单,但网络协议栈的开销让人头疼。而且我们这种本地通信用Socket感觉有点”杀鸡用牛刀”。

共享内存:直接在内存里读写,几乎零开销。虽然同步控制复杂一些,但性能最好。

最终选择了boost::interprocess,主要是因为:

  • 跨平台支持好(我们需要在Windows和Linux上都能跑)
  • 提供了丰富的同步工具(mutex、condition_variable这些)
  • 文档相对完善,踩坑的人多,解决方案也多

实际使用中踩过的坑

第一版设计:太”简洁”了

最开始我们想当然地设计了一个很简单的共享内存结构,就几个基本的flag。结果跑起来才发现,各种边界情况处理不了,只好不断往里加字段。最终的结构长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct SharedSyncData {
// 全局控制
std::atomic<bool> shutdown_requested; // 全局停机信号
std::atomic<uint64_t> case_generation; // 用例版本号
std::atomic<uint64_t> tick_generation; // 时钟版本号
std::atomic<uint32_t> ready_count; // 就绪进程计数

// 进程槽位(每个进程占一个槽位)
ProcessSlot process_slots[MAX_PROCESS_COUNT];

// 同步工具
boost::interprocess::interprocess_mutex mutex;
boost::interprocess::interprocess_condition_any case_cond; // 用例间同步
boost::interprocess::interprocess_condition_any tick_cond; // 用例内同步
};

struct ProcessSlot {
bool active; // 进程是否活跃
bool ready_for_case; // 是否为新用例就绪
bool ready_for_tick; // 是否为下一Tick就绪
char process_name[32]; // 进程名称
uint64_t last_heartbeat; // 最后心跳时间
};

几个血泪教训

1. 内存大小别抠门
一开始分配了64KB,感觉够用了。后来测试场景复杂起来,各种超出边界的crash。最后直接拍板512KB,虽然有点”浪费”,但省心。

2. 同步机制的选择很有讲究

  • 简单的flag用std::atomic,快
  • 复杂的等待/通知用condition_variable,功能强
  • mutex能不用就不用,性能杀手

3. 异常处理必须到位

1
2
3
4
5
6
7
8
9
10
11
12
// 这段代码救过我们很多次
try {
shared_memory_ = boost::interprocess::shared_memory_object(
boost::interprocess::open_or_create,
"iil_test_shared_memory",
boost::interprocess::read_write
);
} catch (const boost::interprocess::interprocess_exception& e) {
// 可能是上次程序异常退出,内存没清理干净
boost::interprocess::shared_memory_object::remove("iil_test_shared_memory");
// 重新来一遍
}

真正的坑在这里

内存对齐的陷阱:有一次在Windows和Linux之间切换测试,程序莫名其妙地crash。调试了半天发现是结构体对齐不一致,Linux下某个字段的偏移和Windows不一样。最后加了#pragma pack(1)强制对齐。

进程异常退出的遗留问题:如果进程crash了,共享内存可能没法正常清理。结果下次启动时会读到脏数据。现在每次启动都会检查并清理可能的遗留内存。

跨平台的权限差异:Linux下共享内存默认权限比较严格,Windows相对宽松。结果就是Linux下偶尔会出现权限问题。后来统一设置了明确的权限参数。

实施过程中踩的坑

最大的坑:时序同步比想象中复杂

最开始我们天真地以为,进程间同步就是”你好了喊一声,我好了也喊一声,然后一起开始”。结果发现,真实情况远比这复杂。

问题1:启动时序不确定
不同进程的启动速度不一样,有的组件初始化快,有的慢。如果不控制好,快的进程可能等半天,慢的进程还在初始化。

问题2:时钟同步的精度要求
我们需要所有进程在完全相同的时间点进入下一个周期,差几微秒都可能影响测试结果。但进程调度、内存访问这些都有不确定性。

最后搞了个”两阶段握手”:

1
2
3
4
5
6
7
8
9
10
11
bool startNewCase(uint64_t ticks_for_this_case) {
// 第一阶段:举手"我准备好了"
shared_data_->process_slots[slot_].ready_for_case = true;
shared_data_->ready_count++;

// 第二阶段:等"老师"说"开始"
while (shared_data_->case_generation == current_generation) {
condition_wait(); // 在这里等待
}
return true;
}

这个方案目前还算稳定,但感觉还有优化空间。

第二个坑:资源清理的连环问题

共享内存的生命周期管理真的很麻烦。一开始我们想当然地以为”用完就删除”,结果遇到各种竞态条件:

  • 进程A刚准备清理,进程B还在访问
  • 进程异常退出,内存没清理,下次启动读到脏数据
  • 多个进程同时尝试清理,互相冲突

最后让ProcessManager当”管家”,统一负责整个生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
void ProcessManager::handleShutdown() {
// 先通知大家"要下班了"
shared_data_->shutdown_requested = true;
broadcast_all_conditions();

// 等大家都走了再锁门
while (hasActiveProcesses()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

// 最后清理现场
cleanup_shared_memory();
}

第三个坑:GTest框架的集成

GTest本来是单进程的框架,现在要让它和多进程协调工作,各种适配问题:

问题:每个TEST_F都是独立的,但我们需要在多个进程间保持状态同步。
解决:在每个测试用例的开头调用startNewCase(),在每个时钟周期调用waitForNextTick()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 主测试进程长这样
int main(int argc, char* argv[]) {
IpcAdapter adapter;
adapter.initialize("wheelbase_main");

return RUN_ALL_TESTS(); // GTest原来怎么跑还怎么跑
}

// 辅助进程长这样
int main(int argc, char* argv[]) {
IpcAdapter adapter;
adapter.initialize("pedal_aux");

// 一直循环,响应同步信号
while (!adapter.isShutdownGlobal()) {
adapter.waitForNextTick();
execute_pedal_component(); // 执行真实组件逻辑
}
return 0;
}

现在看起来还算干净,但集成过程中各种细节问题真的让人头疼。

效果怎么样?老实说,挺意外的

用了几个月,效果比预期好不少:

测试质量确实提升了

最明显的变化:以前Mock测试通过的功能,到实际环境经常出问题。现在这种情况明显减少了。

有个具体的例子:之前测试踏板响应时,Mock总是”完美”地按预期响应。但真实的踏板在温度变化时会有细微的延迟差异。这种差异在Mock环境下永远不会出现,但在实际产品中可能导致手感等体验问题。

用了真实组件后,这类问题在测试阶段就能发现了。

意外收获:真实组件之间的交互测试发现了一些之前没想到的边界情况。比如换挡器和踏板在某些特定时序下的配合问题,这在独立的Mock测试中根本不可能发现。

维护成本确实降低了

不用再维护”两套代码”:这是最直接的收益。以前每次真实组件更新接口,测试团队都要问:Mock要不要同步更新?现在不用了。

调试效率提升:以前出问题要先怀疑是Mock的问题还是测试逻辑的问题,现在直接就是真实组件,问题定位更直接。

但也有一些新问题

资源消耗增加:真实组件比Mock消耗更多CPU和内存,测试环境的配置要求提高了。

启动时间变长:多个进程的初始化和同步需要时间,整体测试启动时间从几秒变成了十几秒。

复杂性增加:虽然不用维护Mock了,但多进程同步的复杂性也不小。出问题时调试更困难。

从产品角度看,这个改进值不值?

作为一个既要考虑技术实现又要考虑成本效益的工程师,我觉得有必要从更宏观的角度评估一下这次架构改进:

短期成本 vs 长期收益

短期成本是明显的:

  • 架构设计和开发投入了大约1个人月
  • 测试环境硬件配置要求提升了
  • 团队学习新架构的时间成本

长期收益目前看来是正面的:

  • Mock维护成本基本归零
  • 测试发现的问题质量更高,减少了生产环境的火灾
  • 测试可信度提升,产品发布决策更有底气

适用性思考

这套架构不是万能的,适用的场景有限:

适合的场景

  • 组件复杂度高,Mock维护成本已经很高
  • 对测试真实性要求严格
  • 组件间交互复杂,需要集成测试

不太适合的场景

  • 组件很简单,Mock几行代码就搞定
  • 测试环境资源有限
  • 团队对多进程同步技术不熟悉

意外的副作用

正面副作用:团队对整个系统架构的理解更深入了。以前各自维护Mock,对其他组件的理解比较肤浅。现在用真实组件,跨组件的协作和理解都提升了。

负面副作用:测试环境变得更”娇贵”了。以前Mock测试很稳定,现在偶尔会因为某个真实组件的问题导致整个测试环境不稳定。

什么情况下值得这么搞?

几个判断标准

经过这次实践,我总结了几个判断是否需要从Mock切换到真实组件的标准:

Mock维护成本的临界点:当你发现维护Mock的时间开始接近甚至超过写真实功能的时间时,就该考虑了。

测试可信度的要求:如果测试结果要直接影响重要的产品决策,或者测试失误的成本很高,真实组件测试的价值就凸显了。

组件复杂度评估:简单的组件(比如一个返回固定值的配置服务)用Mock很合适。但如果组件有复杂的状态机、时序要求,Mock就很难搞了。

实施建议

不要一步到位:我们第一次就想把所有组件都换成真实的,结果差点搞崩。后来改成逐步替换,先选最关键的1-2个组件试点。

同步机制是核心:多进程同步真的是技术难点,建议先把这部分设计好、测试充分,再考虑其他功能。

准备好调试工具:多进程环境下调试比单进程复杂很多,日志、监控、调试工具要提前准备好。

评估资源成本:真实组件的资源消耗可能是Mock的几倍甚至几十倍,要确保测试环境能承受。

写在最后

这次从Mock到真实组件的架构演进,让我对测试策略有了新的认识。

技术没有银弹:Mock有Mock的价值,真实组件有真实组件的优势。关键是要在合适的时机选择合适的方案。

复杂度守恒定律:省掉了Mock维护的复杂度,但增加了多进程同步的复杂度。总的复杂度可能没有减少,只是转移到了不同的地方。

团队成长的价值:虽然技术复杂度增加了,但团队对整个系统的理解更深入了。这种成长的价值可能比技术本身更重要。

最后,每个项目的情况不同,这些经验仅供参考。重要的是要根据自己的实际情况,在测试真实性、开发效率、资源成本之间找到最适合的平衡点。

有时候,最好的架构不是最先进的,而是最适合当前团队和项目的。

  • Title: 从Mock到真实组件:一次测试框架架构演进的实践记录
  • Author: 叫我EC就好
  • Created at : 2025-07-15 18:56:58
  • Updated at : 2025-07-23 15:28:31
  • Link: https://www.o0o0o.sbs/2025/07/15/从Mock到真实组件:一次测试框架架构演进的实践记录/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments