❄️ 我的个人专栏:
《智能软件工程AI4SE》
《嵌入式面试总结》
《嵌入式处理器架构解析》
《嵌入式与虚拟化》
《嵌入式软件测试》
🌟 Simplicity is the ultimate sophistication
摘要:本文介绍嵌入式软件单元测试中隔离外部依赖的 Mock 与 Stub 技术。文章先说明隔离外部依赖的必要性,再对比 Stub 与 Mock 的核心概念与区别,随后介绍链接期替换、函数指针注入、预处理宏替换和硬件抽象层隔离四种嵌入式实现方式,并通过 C 语言示例演示 Stub 与 Mock 的实际用法,最后总结常见陷阱与注意事项。
1. 引言
在嵌入式软件单元测试中,被测函数往往依赖硬件寄存器、外设驱动、操作系统接口或通信协议栈等外部组件。这些依赖在测试环境中可能不可用、行为不确定或代价高昂,导致测试无法稳定运行。Mock 与 Stub 技术正是为了解决这一问题而诞生的测试设计模式,它们通过隔离外部依赖,让被测单元在可控、可观测的环境中独立验证。
2. 为什么需要隔离外部依赖
嵌入式系统的软件层次通常紧贴硬件,被测函数与外部依赖之间存在强耦合。这种耦合在单元测试阶段会带来三类典型问题:
- 环境不可用:传感器、通信总线或外部存储设备在开发机上不存在,测试无法直接调用真实驱动。
- 行为不确定:硬件中断时序、网络延迟或随机噪声会导致测试结果不稳定,难以复现缺陷。
- 代价高昂:依赖真实硬件或外设的测试执行慢、成本高,不适合频繁回归。
通过引入 Mock 与 Stub,测试代码可以替代真实依赖,将测试焦点收敛到被测单元自身的逻辑正确性上。
3. Stub 与 Mock 的核心概念
Stub 与 Mock 常被混用,但二者在测试设计模式中有明确分工。
3.1 Stub:预设返回值的替身
Stub 是一个替身对象,它按照测试需要预先设定返回值或行为,用于让被测单元沿着特定路径执行。Stub 只关心“给什么结果”,不验证被测单元是否以预期方式调用了它。
3.2 Mock:验证交互行为的替身
Mock 不仅提供预设行为,还会记录调用次数、参数顺序和调用时机,并在测试结束时断言这些交互是否符合预期。Mock 关心的是“被测单元是否正确地使用了依赖”。
3.3 二者的区别
| 维度 | Stub | Mock |
|---|---|---|
| 核心目的 | 提供预设返回值,驱动被测路径 | 验证调用交互是否符合预期 |
| 是否断言调用 | 通常不断言 | 必须断言调用次数与参数 |
| 适用场景 | 依赖只提供数据输入 | 依赖的调用顺序和频率是关键逻辑 |
4. 嵌入式环境中的 Mock 与 Stub 实现方式
嵌入式开发中,根据目标平台和工具链的不同,常用的实现方式有以下几种。
4.1 链接期替换
在编译链接阶段,将真实依赖的符号替换为测试桩函数。这种方式无需修改被测源码,适合替换驱动函数和操作系统接口。
4.2 函数指针注入
将被测模块依赖的外部函数封装为函数指针,并在测试初始化时注入替身实现。这种方式适合模块化设计较好的代码。
4.3 预处理宏替换
通过宏定义将真实函数名映射到测试替身,适用于无法修改接口签名的遗留代码。
4.4 硬件抽象层隔离
在驱动层之上建立硬件抽象层,测试时替换为模拟实现。这是嵌入式单元测试中最推荐的长期方案。
5. 代码示例:链接期替换实现 Stub
下面以 C 语言为例,演示如何通过链接期替换隔离温度传感器驱动。
/* 被测模块 temperature_monitor.c */ #include "temperature_monitor.h" #include "sensor_driver.h" float read_temperature_celsius(void) { uint16_t raw = sensor_read_raw(); return (float)raw * 0.01f; }/* 测试桩 sensor_driver_stub.c */ #include "sensor_driver.h" static uint16_t stub_raw_value = 2500u; void sensor_driver_set_raw(uint16_t value) { stub_raw_value = value; } uint16_t sensor_read_raw(void) { return stub_raw_value; }/* 单元测试 test_temperature_monitor.c */ #include "unity.h" #include "temperature_monitor.h" #include "sensor_driver_stub.h" void setUp(void) { sensor_driver_set_raw(2500u); } void test_read_temperature_celsius(void) { TEST_ASSERT_FLOAT_WITHIN(0.01f, 25.0f, read_temperature_celsius()); } void test_raw_value_zero(void) { sensor_driver_set_raw(0u); TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, read_temperature_celsius()); }链接时,将测试工程中的sensor_driver_stub.c与temperature_monitor.c一起编译,真实驱动不参与链接,即可实现隔离。
6. 代码示例:函数指针注入实现 Mock
当被测模块通过函数指针调用外部依赖时,可以在测试中注入 Mock 并验证交互行为。
/* 被测模块 data_logger.c */ #include "data_logger.h" static int (*send_func)(const uint8_t *data, uint16_t len) = NULL; void data_logger_set_sender(int (*func)(const uint8_t *, uint16_t)) { send_func = func; } int log_data(const uint8_t *data, uint16_t len) { if (send_func == NULL) { return -1; } return send_func(data, len); }/* 测试文件 test_data_logger.c */ #include "unity.h" #include "data_logger.h" static int call_count = 0; static uint16_t last_len = 0; static int mock_send(const uint8_t *data, uint16_t len) { call_count++; last_len = len; return 0; } void setUp(void) { call_count = 0; last_len = 0; data_logger_set_sender(mock_send); } void test_log_data_calls_sender_once(void) { uint8_t buf[4] = {1, 2, 3, 4}; TEST_ASSERT_EQUAL_INT(0, log_data(buf, 4)); TEST_ASSERT_EQUAL_INT(1, call_count); TEST_ASSERT_EQUAL_UINT16(4, last_len); } void test_log_data_without_sender(void) { data_logger_set_sender(NULL); TEST_ASSERT_EQUAL_INT(-1, log_data(NULL, 0)); TEST_ASSERT_EQUAL_INT(0, call_count); }通过断言call_count和last_len,测试验证了被测单元确实以正确的参数调用了依赖,这正是 Mock 的核心价值。
7. 常见陷阱与注意事项
- 过度 Mock 导致测试失真:如果替身行为与真实依赖差异过大,测试可能掩盖集成缺陷,应尽量保持替身行为与真实接口一致。
- 忽略返回值边界:Stub 应覆盖正常值、边界值和异常值,避免只测一条快乐路径。
- 链接期替换的符号冲突:多个测试文件同时定义同名桩函数会导致链接错误,建议按模块划分桩文件。
- 函数指针注入的初始化遗漏:每个测试用例的
setUp中都要重新注入替身,防止用例间状态泄漏。 - 硬件抽象层设计滞后:尽早建立硬件抽象层,避免后期大规模重构。
8. 总结
Mock 与 Stub 是嵌入式单元测试中隔离外部依赖的两种互补设计模式。Stub 提供预设行为以驱动被测路径,Mock 进一步验证交互是否符合预期。链接期替换、函数指针注入、预处理宏替换和硬件抽象层隔离是嵌入式环境中常用的实现手段。合理运用这些技术,可以显著提升单元测试的稳定性、可重复性和缺陷定位效率。