17、测试回调函数:对函数指针参数进行测试、验证回调被正确调用
回调函数,说白了就是函数指针。你写一个函数,把另一个函数的地址传进去,让它在合适的时机调用你。这种模式在C语言里太常见了——事件驱动、异步处理、状态机、驱动层向上层报告数据……几乎无处不在。
但问题来了:你怎么测试它?
我刚开始做嵌入式测试时,遇到回调函数就头疼。函数指针指向谁?调没调?参数对不对?次数够不够?这些在代码里都是“看不见”的。你没法直接断言一个函数指针被调用了——它又不是一个变量。
嗯,今天我们就来聊聊怎么搞定它。
回调函数测试的核心难点
先理清楚,回调函数测试到底难在哪:
- 调用不可见:回调是在被调函数内部触发的,测试代码无法直接观察
- 参数传递:回调携带的参数是否正确,需要验证
- 调用次数:回调可能被调用0次、1次、多次,你得确认次数符合预期
- 调用顺序:多个回调时,顺序对不对?
- 副作用:回调可能修改全局状态,测试后需要清理
说白了,我们需要一种方法,把“看不见的调用”变成“可断言的事实”。
核心思路:用“桩函数”捕获调用信息
我个人习惯的做法是——写一个桩函数(stub function),代替真实的回调。这个桩函数不干正事,只记录:
- 自己被调用了
- 被调用时传入了什么参数
- 被调用了多少次
然后测试代码去检查这些记录。你想想看,这不就把回调变成了可测的吗?
核心思想:用桩函数代替真实回调,通过桩函数内部的状态变量,把“调用行为”转化为“可断言的数据”。
实战:一个典型场景
假设我们有这样一个函数:
// 定时器模块,注册一个回调,在超时时调用
typedef void (*timer_callback_t)(int timer_id, void *user_data);
void timer_start(int timeout_ms, timer_callback_t cb, void *user_data);
我们要测试:调用 timer_start 后,当超时发生时,回调是否被正确调用。
怎么测?写一个桩函数:
// 桩函数:记录调用信息
static int g_cb_called = 0;
static int g_cb_timer_id = -1;
static void *g_cb_user_data = NULL;
void my_timer_stub(int timer_id, void *user_data) {
g_cb_called++;
g_cb_timer_id = timer_id;
g_cb_user_data = user_data;
}
// 测试前重置
void reset_timer_stub(void) {
g_cb_called = 0;
g_cb_timer_id = -1;
g_cb_user_data = NULL;
}
然后测试用例就可以这样写:
void test_timer_callback_invoked(void) {
reset_timer_stub();
int my_data = 42;
timer_start(100, my_timer_stub, &my_data);
// 模拟超时发生(假设内部有 tick 函数)
timer_tick(100);
// 断言回调被调用
TEST_ASSERT_EQUAL(1, g_cb_called);
TEST_ASSERT_EQUAL(0, g_cb_timer_id);
TEST_ASSERT_EQUAL(&my_data, g_cb_user_data);
}
你看,逻辑很清晰。桩函数记录了调用信息,测试代码直接断言这些记录。
小技巧:如果回调可能被多次调用,可以用数组记录每次调用的参数。比如 g_cb_args[10],然后断言第几次调用的参数是什么。我在项目中遇到过需要验证连续三次回调的场景,用数组记录就特别方便。
验证回调未被调用
有时候,我们需要确保回调没有被调用。比如条件不满足时,不应该触发回调。
我曾经踩过一个坑:某个驱动在初始化失败时,错误地调用了完成回调,导致上层逻辑以为操作成功了。后来我加了一个测试:
void test_callback_not_invoked_on_error(void) {
reset_timer_stub();
// 传入非法参数,预期不会触发回调
timer_start(-1, my_timer_stub, NULL);
timer_tick(100);
TEST_ASSERT_EQUAL(0, g_cb_called);
}
这个测试帮我抓到了好几次回归引入的bug。嗯,别小看“没调用”的验证,它和“调用了”一样重要。
验证回调调用次数
有些场景下,回调应该被调用多次。比如一个数据采集模块,每采集到一个样本就回调一次。
这时候,桩函数里维护一个计数器就够了:
void test_callback_called_multiple_times(void) {
reset_timer_stub();
for (int i = 0; i < 5; i++) {
data_acquisition_trigger();
}
TEST_ASSERT_EQUAL(5, g_cb_called);
}
如果回调需要区分每次调用的参数,可以用数组:
static int g_cb_values[10];
static int g_cb_count;
void my_callback_stub(int value) {
if (g_cb_count < 10) {
g_cb_values[g_cb_count] = value;
}
g_cb_count++;
}
void test_callback_values_sequence(void) {
reset_stub();
int expected[] = {10, 20, 30};
process_data_batch(expected, 3);
TEST_ASSERT_EQUAL(3, g_cb_count);
TEST_ASSERT_EQUAL(10, g_cb_values[0]);
TEST_ASSERT_EQUAL(20, g_cb_values[1]);
TEST_ASSERT_EQUAL(30, g_cb_values[2]);
}
回调函数测试的完整流程
我把整个测试流程画了一张图,方便你理解:
进阶:回调的“副作用”测试
回调函数往往不只是“被调用”就完了,它还会修改一些数据。比如一个网络协议栈的回调,可能会修改缓冲区内容。
这时候,桩函数不仅要记录,还要模拟真实回调的行为:
static uint8_t g_buffer[256];
static size_t g_buffer_len;
void data_callback_stub(uint8_t *data, size_t len) {
g_cb_called++;
// 模拟真实回调:拷贝数据
if (len <= 256) {
memcpy(g_buffer, data, len);
g_buffer_len = len;
}
}
void test_data_callback_modifies_buffer(void) {
reset_stub();
uint8_t input[] = {0x01, 0x02, 0x03};
process_data(input, 3, data_callback_stub);
TEST_ASSERT_EQUAL(3, g_buffer_len);
TEST_ASSERT_EQUAL_MEMORY(input, g_buffer, 3);
}
注意:如果桩函数模拟了真实行为,一定要确保它不会引入新的bug。我见过有人把桩函数写得比真实代码还复杂,结果测试失败都不知道是桩的问题还是被测代码的问题。桩函数越简单越好,能记录信息就够了,不要过度模拟。
多回调场景的测试
有些模块支持注册多个回调,比如一个事件系统,不同事件触发不同回调。这时候,我建议为每个回调单独写一个桩函数,或者用一个结构体统一管理:
typedef struct {
int called;
int event_id;
void *data;
} callback_record_t;
static callback_record_t g_records[10];
static int g_record_count;
void event_callback_stub(int event_id, void *data) {
if (g_record_count < 10) {
g_records[g_record_count].called = 1;
g_records[g_record_count].event_id = event_id;
g_records[g_record_count].data = data;
}
g_record_count++;
}
void test_multiple_events(void) {
reset_records();
trigger_events();
TEST_ASSERT_EQUAL(3, g_record_count);
TEST_ASSERT_EQUAL(EVENT_A, g_records[0].event_id);
TEST_ASSERT_EQUAL(EVENT_B, g_records[1].event_id);
TEST_ASSERT_EQUAL(EVENT_C, g_records[2].event_id);
}
总结一下
测试回调函数,说白了就是三步:
- 写桩函数:替代真实回调,记录调用信息
- 触发条件:执行被测代码,让回调被调用
- 断言验证:检查桩函数记录的信息是否符合预期
这个方法我在多个项目里用过,从简单的定时器回调,到复杂的协议栈事件分发,都能搞定。你想想看,只要能把“调用行为”变成“可断言的数据”,回调函数就不再是测试的盲区了。
嗯,今天就聊到这里。记住:桩函数越简单越好,记录信息越明确越好。别把桩函数写成第二个被测函数。
公众号:蓝海资料掘金营,微信deep3321