桩函数设计原理:定义、作用与分类
单元测试里有个绕不开的话题——桩函数。说白了,桩函数就是替身演员。被测试的模块需要调用某个外部函数,但这个外部函数要么还没写好,要么依赖硬件,要么有副作用,你不能真去调用它。这时候就得写个假的顶上去。
我记得刚入行那会儿,接手一个电机控制模块的测试。底层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);
}
三类桩函数的对比
| 类型 | 核心关注点 | 典型用法 | 验证方式 |
|---|---|---|---|
| 输入桩 | 返回值 | 模拟传感器数据、配置参数 | 断言被测函数的输出 |
| 输出桩 | 调用参数 | 验证写入寄存器、发送消息 | 断言桩函数记录的参数 |
| 行为桩 | 调用序列 | 模拟重试、超时、状态切换 | 断言调用次数和顺序 |
桩函数分类知识图谱
下面这张图把三类桩函数的关系和典型应用场景串起来了,方便你对照理解。
实际项目中的选择策略
三类桩函数怎么选?我个人的经验是:
- 被测模块需要「读」外部数据——用输入桩。比如读传感器、读配置文件、读系统时间。
- 被测模块需要「写」外部设备——用输出桩。比如写寄存器、发消息、写日志。
- 被测模块涉及多次交互或状态变化——用行为桩。比如通信重试、状态机切换、超时处理。
一个小技巧:刚开始写桩函数时,先问自己三个问题——这个函数返回什么?这个函数被谁调用?这个函数被调了几次?答案清楚了,桩函数的类型也就定了。