第四章 桩函数设计原则:让模拟更真实
桩函数写得好不好,直接决定集成测试的可信度。我见过太多团队,桩函数写得跟玩具似的——接口对不上、行为太理想、返回值随便填。结果呢?集成一跑就崩,还找不到根因。
说白了,桩函数就是被测模块的「替身演员」。替身演得不像,主角的戏就全毁了。今天我把这些年总结的五个核心原则掰开揉碎讲清楚。
4.1 桩函数接口匹配原则
接口匹配是桩函数的第一道红线。你想想看,如果桩函数的参数个数、类型、顺序跟真实函数不一样,那被测模块调用时要么编译报错,要么运行时栈崩掉。
匹配什么?
- 函数名:必须与被桩函数完全一致(包括大小写)
- 参数列表:参数个数、类型、顺序、const/volatile 修饰符都要一致
- 返回值类型:void、int、指针、结构体,一个都不能错
- 调用约定:__cdecl、__stdcall 这些,尤其在跨编译器场景下
我在项目中遇到过最坑的一次:有个同事写桩函数时把参数顺序写反了,编译没报错(因为类型相同),结果跑测试时数据全乱套。查了两天才发现是桩函数接口没对齐。
// 真实函数声明
int sensor_read(uint8_t channel, uint16_t *value, uint32_t timeout_ms);
// 桩函数声明(完全一致)
int sensor_read(uint8_t channel, uint16_t *value, uint32_t timeout_ms)
{
// 桩函数体
*value = 0x1234;
return 0;
}
4.2 桩函数行为模拟原则
接口对了只是第一步。更关键的是——桩函数的行为要像真的。
什么叫「行为像」?我举个例子:真实模块可能有状态机、有超时、有错误重试。桩函数如果只是简单返回一个固定值,那被测模块的很多逻辑分支根本覆盖不到。
行为模拟的层次:
| 层次 | 说明 | 适用场景 |
|---|---|---|
| 静态返回 | 每次调用返回固定值 | 简单测试、快速验证 |
| 序列返回 | 按调用次数返回不同值 | 测试状态切换、时序逻辑 |
| 条件返回 | 根据输入参数决定返回值 | 边界测试、异常注入 |
| 状态模拟 | 维护内部状态,模拟真实模块行为 | 复杂协议栈、驱动测试 |
我个人习惯:能用序列返回就别用静态返回。因为很多 bug 是在第二次、第三次调用时才暴露的。
// 序列返回示例
static int call_count = 0;
int sensor_read(uint8_t channel, uint16_t *value, uint32_t timeout_ms)
{
int ret;
call_count++;
switch(call_count) {
case 1: *value = 100; ret = 0; break; // 第一次成功
case 2: *value = 0; ret = -1; break; // 第二次超时
case 3: *value = 200; ret = 0; break; // 第三次恢复
default: ret = -2; break; // 后续报错
}
return ret;
}
4.3 桩函数返回值设计
返回值设计是桩函数里最容易偷懒的地方。很多人直接写 return 0; 就完事了。嗯,这里要注意——返回值决定了被测模块的路径覆盖率。
设计原则:
- 覆盖所有返回值:成功、失败、超时、参数错误,一个都不能少
- 边界值优先:最大值、最小值、空指针、零长度
- 异常值注入:模拟硬件故障、通信中断、资源耗尽
- 返回值与输出参数联动:返回值变了,输出参数也要相应变化
// 返回值与输出参数联动示例
int dma_transfer(uint32_t *src, uint32_t *dst, uint32_t len)
{
// 模拟不同场景
if (src == NULL || dst == NULL) {
return -1; // 参数错误
}
if (len > 1024) {
return -2; // 长度超限
}
if (len == 0) {
return 0; // 空传输也算成功
}
// 正常传输:把源数据拷贝到目标
for (uint32_t i = 0; i < len; i++) {
dst[i] = src[i];
}
return len; // 返回实际传输字节数
}
4.4 桩函数副作用控制
副作用,说白了就是桩函数执行后对外部环境造成的影响。真实模块可能有副作用——写寄存器、发中断、改全局变量。桩函数要不要模拟这些副作用?
我的判断标准:
- 被测模块依赖的副作用必须模拟:比如全局状态标志、回调函数
- 被测模块不关心的副作用可以忽略:比如硬件 LED 闪烁、日志打印
- 危险的副作用绝对不能有:比如写真实硬件寄存器、触发真实中断
// 副作用控制示例
static int g_sensor_ready = 0; // 全局状态,被测模块会检查
int sensor_init(uint8_t bus_id)
{
// 只模拟被测模块关心的副作用
g_sensor_ready = 1; // 标记传感器就绪
// 不模拟的副作用(注释掉)
// hardware_led_on(); // 硬件操作,测试环境不能做
// log_printf("init ok"); // 日志打印,不影响逻辑
return 0;
}
int sensor_check_ready(void)
{
return g_sensor_ready; // 被测模块依赖这个状态
}
4.5 桩函数复杂度控制
最后一个原则,也是很多人容易走极端的——桩函数到底该写多复杂?
写得太简单,模拟不真实;写得太复杂,本身就成了一个需要测试的模块。你想想看,如果桩函数里还有状态机、有定时器、有回调注册,那它还算「桩」吗?
复杂度控制原则:
| 场景 | 推荐复杂度 | 说明 |
|---|---|---|
| 单元测试 | 低 | 静态返回或简单序列返回即可 |
| 集成测试 | 中 | 需要模拟状态和部分行为 |
| 系统测试 | 高 | 需要完整模拟模块行为 |
| 硬件不可用 | 中高 | 替代硬件驱动,但不要过度设计 |
还有一点——桩函数里不要加断言、不要加日志、不要加性能统计。这些「测试增强」代码会改变桩函数的行为,让测试结果失真。
// 复杂度适中的桩函数(推荐)
int spi_transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len)
{
// 简单参数校验
if (tx_buf == NULL || rx_buf == NULL || len == 0) {
return -1;
}
// 回环模式:发送什么就接收什么
for (uint16_t i = 0; i < len; i++) {
rx_buf[i] = tx_buf[i];
}
return 0;
}
// 过度设计的桩函数(不推荐)
int spi_transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len)
{
// 不要这样做!
assert(tx_buf != NULL);
assert(rx_buf != NULL);
assert(len <= 4096);
log_printf("spi_transfer: len=%d", len);
// 模拟各种硬件状态
static int state = 0;
state = (state + 1) % 3;
if (state == 1) return -2; // 模拟忙状态
// 模拟 DMA 传输...
// 模拟中断...
// 模拟超时重试...
// 这已经是一个完整的驱动了,不是桩函数!
}
知识体系总览
五个原则之间的关系,我用一张图来总结:
这五个原则不是孤立的。接口匹配是底线,行为模拟和返回值设计决定了测试能覆盖多深,副作用控制和复杂度控制则保证桩函数本身的质量。我在实际项目中,每次写桩函数前都会拿这五条过一遍——缺哪条补哪条,基本不会出大问题。