第四章 桩函数设计原则:让模拟更真实

桩函数写得好不好,直接决定集成测试的可信度。我见过太多团队,桩函数写得跟玩具似的——接口对不上、行为太理想、返回值随便填。结果呢?集成一跑就崩,还找不到根因。

说白了,桩函数就是被测模块的「替身演员」。替身演得不像,主角的戏就全毁了。今天我把这些年总结的五个核心原则掰开揉碎讲清楚。

核心观点:桩函数不是「能编译过就行」,而是要「模拟得像真模块一样」。五个原则缺一不可。

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 桩函数复杂度控制

最后一个原则,也是很多人容易走极端的——桩函数到底该写多复杂?

写得太简单,模拟不真实;写得太复杂,本身就成了一个需要测试的模块。你想想看,如果桩函数里还有状态机、有定时器、有回调注册,那它还算「桩」吗?

复杂度控制原则:

场景 推荐复杂度 说明
单元测试 低 静态返回或简单序列返回即可
集成测试 中 需要模拟状态和部分行为
系统测试 高 需要完整模拟模块行为
硬件不可用 中高 替代硬件驱动,但不要过度设计
我的经验法则:桩函数的代码行数不要超过真实函数的 1.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 传输...
    // 模拟中断...
    // 模拟超时重试...
    // 这已经是一个完整的驱动了,不是桩函数!
}

知识体系总览

五个原则之间的关系,我用一张图来总结:

桩函数设计五原则 桩函数设计 接口匹配 函数名/参数/返回值 行为模拟 状态/序列/条件 返回值设计 边界/异常/联动 副作用控制 必要/忽略/危险 复杂度控制 场景匹配/适度设计 核心思想 五个原则相互制约:接口匹配是基础,行为模拟和返回值设计决定测试覆盖率 副作用控制和复杂度控制保证桩函数本身的可信度和可维护性

这五个原则不是孤立的。接口匹配是底线,行为模拟和返回值设计决定了测试能覆盖多深,副作用控制和复杂度控制则保证桩函数本身的质量。我在实际项目中,每次写桩函数前都会拿这五条过一遍——缺哪条补哪条,基本不会出大问题。