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]);
}

回调函数测试的完整流程

我把整个测试流程画了一张图,方便你理解:

回调函数测试流程 步骤1:编写桩函数 记录调用次数和参数 步骤2:注册桩函数 替代真实回调传入被测函数 步骤3:执行被测函数 触发回调条件 步骤4:回调被调用 桩函数记录调用信息 步骤5:检查记录 读取桩函数的状态变量 步骤6:断言验证 断言调用次数、参数等 测试通过:回调被正确调用 ✓

进阶:回调的“副作用”测试

回调函数往往不只是“被调用”就完了,它还会修改一些数据。比如一个网络协议栈的回调,可能会修改缓冲区内容。

这时候,桩函数不仅要记录,还要模拟真实回调的行为:

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);
}

总结一下

测试回调函数,说白了就是三步:

  1. 写桩函数:替代真实回调,记录调用信息
  2. 触发条件:执行被测代码,让回调被调用
  3. 断言验证:检查桩函数记录的信息是否符合预期

这个方法我在多个项目里用过,从简单的定时器回调,到复杂的协议栈事件分发,都能搞定。你想想看,只要能把“调用行为”变成“可断言的数据”,回调函数就不再是测试的盲区了。

嗯,今天就聊到这里。记住:桩函数越简单越好,记录信息越明确越好。别把桩函数写成第二个被测函数。


公众号:蓝海资料掘金营,微信deep3321