第20章 测试接口设计:如何为模块设计可测试的接口,Mock对象与桩函数的实现

说实话,很多嵌入式工程师对测试这件事是又爱又恨。爱的是它能帮你提前发现bug,恨的是——硬件还没出来,外设还没连上,你拿什么测?

我早年做的一个车载项目,就吃过这个亏。当时写了一个温度采集模块,逻辑自认为天衣无缝。结果硬件联调那天,一上电就死机。查了两天才发现,是ADC返回的原始值没做边界处理。要是当时能写个桩函数模拟一下异常数据,半小时就能搞定的事,硬是拖了两天。

所以这一章,咱们就聊聊怎么给模块设计可测试的接口,以及Mock对象和桩函数到底怎么落地。

20.1 为什么接口设计要“可测试”?

你想想看,一个模块如果跟硬件绑得太死,比如直接在函数里调用HAL_ADC_Read(),那测试的时候怎么办?没有硬件,这函数就废了。

可测试的接口,说白了就是让模块的依赖关系变得“可替换”。

  • 依赖注入:把外部依赖通过参数传进来,而不是在函数内部硬编码。
  • 接口抽象:用函数指针或结构体封装操作,测试时换成Mock实现。
  • 状态可观测:模块内部的关键状态,留一个“窥视孔”给测试代码。

核心原则:模块不应该知道它正在被测试。它只认接口,不认实现。

20.2 Mock对象与桩函数:到底有什么区别?

这两个词经常被混用,但在我眼里它们分工不同:

概念 作用 典型场景
桩函数(Stub) 返回预设值,不执行真实逻辑 模拟ADC读取、传感器数据
Mock对象 记录调用次数、参数,验证行为 检查某个回调是否被正确调用

举个例子:你写了一个温度采集模块,它依赖一个read_sensor()函数。测试时:

  • 桩函数:直接返回25.3,不管你怎么调用。
  • Mock对象:不仅返回25.3,还记录你调了几次、传了什么参数。最后断言“必须调用恰好3次”。

我个人习惯是:验证返回值用Stub,验证交互行为用Mock。别混着用,否则测试代码会变得很难维护。

20.3 实战:给一个传感器驱动设计可测试接口

假设我们有一个温湿度传感器模块,真实环境下通过I2C读取。传统写法可能是这样的:

// 传统写法:硬编码依赖
float read_temperature(void) {
    uint8_t buf[2];
    HAL_I2C_Master_Receive(&hi2c1, 0x40, buf, 2, 100);
    return (buf[0] << 8 | buf[1]) * 0.01f;
}

这代码没法测。因为HAL_I2C_Master_Receive是硬件相关的。

怎么改?用函数指针把I2C操作抽象出来:

// 可测试版本:依赖注入
typedef struct {
    int (*i2c_read)(uint8_t dev_addr, uint8_t *data, uint16_t len);
} sensor_io_t;

float read_temperature(sensor_io_t *io) {
    uint8_t buf[2];
    if (io->i2c_read(0x40, buf, 2) != 0) {
        return -1.0f;  // 错误处理
    }
    return (buf[0] << 8 | buf[1]) * 0.01f;
}

现在测试的时候,我们可以传入一个桩函数:

// 桩函数:模拟I2C读取
static int stub_i2c_read(uint8_t dev_addr, uint8_t *data, uint16_t len) {
    (void)dev_addr;
    (void)len;
    data[0] = 0x1A;  // 模拟温度高字节
    data[1] = 0x2B;  // 模拟温度低字节
    return 0;
}

void test_read_temperature(void) {
    sensor_io_t mock_io = { .i2c_read = stub_i2c_read };
    float temp = read_temperature(&mock_io);
    // 断言 temp 接近 (0x1A << 8 | 0x2B) * 0.01
}

小技巧:桩函数里可以加一个静态计数器,用来模拟“连续读取三次后返回错误”这种边界场景。我在项目中经常用这招来测试重试逻辑。

20.4 更高级的Mock:记录调用行为

有时候光返回固定值不够,你还得验证模块是否按预期调用了外部接口。比如一个看门狗喂狗模块,你得确认它在超时前确实调用了feed_watchdog()

这时候就需要一个带记录的Mock:

// Mock对象:记录调用次数和参数
static int mock_feed_call_count = 0;

void mock_feed_watchdog(void) {
    mock_feed_call_count++;
}

void test_watchdog_feeding(void) {
    mock_feed_call_count = 0;
    run_main_loop();  // 假设这个函数内部会喂狗
    assert(mock_feed_call_count > 0);  // 至少喂了一次
}

嗯,这里要注意:每次测试前记得重置计数器。我见过有人忘了重置,结果测试永远通过,因为上一次的计数残留导致断言失效。

20.5 避坑指南:我曾经踩过的三个坑

坑一:桩函数写得太“聪明”

我曾经写过一个桩函数,里面居然包含了真实算法的简化版。结果测试通过了,但真实硬件上跑出来完全不对。因为桩函数和真实实现的bug一模一样。

记住:桩函数只返回预设值,不要复制逻辑

坑二:Mock对象和Stub混用导致测试不可读

一个测试用例里,既用Mock验证调用次数,又用Stub返回数据,还顺手改了全局状态。最后测试失败了,你根本分不清是哪个环节出了问题。

我的建议是:一个测试用例只验证一件事。要么测返回值,要么测交互行为。

坑三:接口抽象过度

为了可测试,把每个函数都变成函数指针,结果接口结构体比实际代码还复杂。这属于过度设计。

我的经验是:只对“外部依赖”做抽象。比如硬件外设、文件系统、网络协议栈。模块内部的辅助函数,直接调用就好,不用Mock。

20.6 本章知识体系

下面这张图总结了测试接口设计的核心思路:

测试接口设计核心思路 可测试接口 依赖注入 接口抽象(函数指针) 状态可观测 参数传递外部依赖 Stub / Mock 替换 留测试钩子(hook) 结构体封装IO操作 函数指针 + 回调注册 全局变量 / 静态变量暴露

重要提醒:测试接口设计不是一蹴而就的。我建议你在写模块的第一版代码时,就顺手把接口抽象出来。等代码写完了再回头加测试接口,往往要动大手术,成本高得多。

好了,这一章的内容就到这里。测试接口设计这件事,说白了就是“为未来的自己留一条后路”。你永远不知道明天会不会突然要换硬件、改协议、加功能。接口抽象得好,测试代码就是你的安全网。

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