桩函数设计原理:定义、作用与分类

单元测试里有个绕不开的话题——桩函数。说白了,桩函数就是替身演员。被测试的模块需要调用某个外部函数,但这个外部函数要么还没写好,要么依赖硬件,要么有副作用,你不能真去调用它。这时候就得写个假的顶上去。

我记得刚入行那会儿,接手一个电机控制模块的测试。底层ADC读取函数依赖真实硬件,测试环境里根本没有。我当时硬着头皮把整个测试用例都跳过了,结果上线前才发现好几个逻辑分支根本没覆盖到。后来被老工程师教育了一顿,才真正搞懂桩函数该怎么设计。

桩函数的定义

桩函数(Stub Function),是单元测试中用来替换真实依赖函数的模拟实现。它保留与被替换函数相同的接口签名,但内部逻辑被简化或完全替换,只返回测试用例需要的预设值或执行特定行为。

核心定义三要素:

  • 接口一致——函数名、参数列表、返回值类型必须与原函数完全相同
  • 行为可控——返回值或执行路径由测试用例决定,而非真实逻辑
  • 轻量无副作用——不操作真实硬件、不写文件、不发网络请求

举个例子,假设被测函数调用了 int read_temperature(void),真实实现会去读I2C传感器。测试时你写个桩函数,直接返回25或者-10,用来验证不同温度下的逻辑分支。

// 真实函数(依赖硬件)
int read_temperature(void) {
    uint8_t buf[2];
    i2c_read(0x48, 0x00, buf, 2);
    return (buf[0] << 8 | buf[1]) / 128;
}

// 桩函数(测试用)
int __stub_read_temperature(void) {
    // 返回值由测试用例通过全局变量控制
    return g_stub_temperature;
}

桩函数的作用

桩函数到底解决了什么问题?我总结下来,核心作用有四个:

作用 说明 典型场景
解耦依赖 将被测模块与外部依赖隔离 硬件驱动、操作系统API、第三方库
模拟异常 构造真实环境难以触发的边界条件 传感器故障、通信超时、内存分配失败
加速执行 避免等待硬件响应或延时操作 延时函数、轮询等待、外部中断
确定性验证 每次测试返回固定值,结果可复现 随机数生成、时间戳、唯一ID生成

我的经验:桩函数最大的价值不是「让测试能跑」,而是「让测试能跑出你想要的场景」。我曾经花了一整天去复现一个偶发的看门狗复位问题,最后用桩函数直接模拟了连续三次的通信超时,十分钟就定位到了bug。

桩函数的分类

根据桩函数在测试中扮演的角色,我习惯把它们分成三类:输入桩、输出桩、行为桩。这三类各有各的用法,混着用也很常见。

1. 输入桩(Input Stub)

输入桩负责提供数据。被测模块调用它,它返回一个预设值,这个值会作为后续逻辑的输入。

说白了,就是「喂数据」的。你想想看,被测函数里有个 get_sensor_value(),你希望它返回0、返回最大值、返回负值,分别验证不同分支——这就是输入桩的活儿。

// 输入桩示例:模拟不同温度值
int stub_get_temperature(void) {
    // 测试用例通过全局变量控制返回值
    return g_stub_temp;
}

// 测试用例中
void test_high_temp_alarm(void) {
    g_stub_temp = 85;  // 设置高温
    uint8_t alarm = check_temperature_alarm();
    TEST_ASSERT_EQUAL(1, alarm);
}

void test_normal_temp(void) {
    g_stub_temp = 25;
    uint8_t alarm = check_temperature_alarm();
    TEST_ASSERT_EQUAL(0, alarm);
}

注意:输入桩的返回值必须覆盖所有可能的输入范围,包括正常值、边界值、异常值。我曾经漏掉了0值的情况,结果被测函数里有个除以输入值的操作,测试时没暴露,上线直接除零复位。

2. 输出桩(Output Stub)

输出桩负责捕获数据。被测模块调用它时,会把参数传进来,桩函数不执行真实操作,而是把参数记录下来,供测试用例后续断言验证。

输出桩的核心是「验证调用是否正确」。比如你的模块调用了 set_motor_speed(100),你不需要真的让电机转,但你要确认这个函数被调用了,而且参数确实是100。

// 输出桩示例:捕获电机速度设置
int g_stub_motor_speed = 0;
int g_stub_motor_called = 0;

void stub_set_motor_speed(int speed) {
    g_stub_motor_speed = speed;
    g_stub_motor_called = 1;
}

// 测试用例中
void test_motor_speed_set(void) {
    g_stub_motor_called = 0;
    g_stub_motor_speed = 0;

    control_motor(50);  // 被测函数内部会调用 stub_set_motor_speed

    TEST_ASSERT_EQUAL(1, g_stub_motor_called);
    TEST_ASSERT_EQUAL(50, g_stub_motor_speed);
}

3. 行为桩(Behavior Stub)

行为桩最复杂,也最灵活。它不仅要返回数据或捕获数据,还要模拟特定的行为序列——比如第一次调用返回成功,第二次调用返回失败;或者第一次调用耗时5ms,第二次调用直接超时。

我遇到过最典型的场景是通信重试逻辑。被测模块会尝试三次连接,前两次失败,第三次成功。没有行为桩,你根本测不了这种时序相关的逻辑。

// 行为桩示例:模拟三次通信重试
int g_stub_retry_count = 0;

int stub_connect(void) {
    g_stub_retry_count++;
    if (g_stub_retry_count <= 2) {
        return -1;  // 前两次失败
    }
    return 0;       // 第三次成功
}

// 测试用例中
void test_retry_three_times(void) {
    g_stub_retry_count = 0;
    int result = establish_connection();
    TEST_ASSERT_EQUAL(0, result);
    TEST_ASSERT_EQUAL(3, g_stub_retry_count);
}

三类桩函数的对比

类型 核心关注点 典型用法 验证方式
输入桩 返回值 模拟传感器数据、配置参数 断言被测函数的输出
输出桩 调用参数 验证写入寄存器、发送消息 断言桩函数记录的参数
行为桩 调用序列 模拟重试、超时、状态切换 断言调用次数和顺序

桩函数分类知识图谱

下面这张图把三类桩函数的关系和典型应用场景串起来了,方便你对照理解。

桩函数分类知识图谱 桩函数 (Stub) 输入桩 输出桩 行为桩 核心作用 提供预设返回值 模拟传感器数据、配置参数 验证被测函数输出 核心作用 捕获调用参数 验证写入寄存器、发送消息 断言桩函数记录参数 核心作用 模拟调用序列 模拟重试、超时、状态切换 断言调用次数和顺序 设计原则:接口一致 + 行为可控 + 轻量无副作用 输入桩喂数据 · 输出桩抓数据 · 行为桩控流程 实际项目中三类桩函数经常组合使用

实际项目中的选择策略

三类桩函数怎么选?我个人的经验是:

  • 被测模块需要「读」外部数据——用输入桩。比如读传感器、读配置文件、读系统时间。
  • 被测模块需要「写」外部设备——用输出桩。比如写寄存器、发消息、写日志。
  • 被测模块涉及多次交互或状态变化——用行为桩。比如通信重试、状态机切换、超时处理。

一个小技巧:刚开始写桩函数时,先问自己三个问题——这个函数返回什么?这个函数被谁调用?这个函数被调了几次?答案清楚了,桩函数的类型也就定了。