第1章:函数与测试——单元测试中的函数桩与模拟

做嵌入式开发这么多年,我踩过最大的坑之一,就是代码写完了才发现没法测。

你想想看,一个函数依赖硬件寄存器、依赖另一个模块的状态、依赖某个全局变量……你单独拎出来跑,它根本跑不起来。这时候你怎么办?

嗯,这就是我们今天要聊的——函数桩(Stub)模拟(Mock)

1.1 为什么需要桩和模拟?

说白了,单元测试的目标是「测一个函数,而不是测整个系统」。但现实是,大部分函数都不是孤岛——它会调用别的函数、读写硬件、或者依赖某个外部状态。

我在项目中遇到过这样一个场景:一个温度采集函数,内部调用了 I2C_Read()。如果直接测,你得接上传感器、上电、等温度变化……这哪是单元测试?这明明是集成测试。

所以我们需要替身——用桩函数代替真实的 I2C 读取,用模拟对象代替复杂的依赖关系。

核心原则: 只测当前函数的行为,不测它依赖的东西。

1.2 函数桩(Stub)——最简单的替身

桩函数是最基础的测试替身。它不做任何实际工作,只返回一个预设的值。

举个例子:

// 真实函数
int Get_Temperature(void) {
    return I2C_Read(0x48);  // 读传感器
}

// 桩函数
int Get_Temperature(void) {
    return 25;  // 固定返回25度
}

你看,桩函数就这么简单。它存在的意义是:让被测代码能编译通过、能跑起来

我个人习惯在测试文件中这样组织桩函数:

// test_stub.c
#include "stub.h"

int Stub_GetTemperature(void) {
    return 25;
}

int Stub_GetPressure(void) {
    return 100;
}

// 用一个结构体管理所有桩的返回值
typedef struct {
    int temperature;
    int pressure;
    int humidity;
} Stub_Config;

Stub_Config g_stub_cfg = {25, 100, 60};

这样做的好处是:你可以在测试用例里随时修改桩的返回值,不用重新编译整个文件。

我的经验: 桩函数的名字最好加个前缀,比如 Stub_Fake_,一眼就能看出来这是测试代码,不是生产代码。

1.3 模拟(Mock)——更聪明的替身

桩函数只能返回固定值,但有时候我们需要验证「这个函数有没有被调用?」「调了几次?」「传了什么参数?」。

这就是模拟(Mock)的用武之地。

我曾经在一个通信协议栈的项目里,需要测试一个数据发送函数。它内部会调用 UART_SendByte()。我想确认的是:发送函数是否按正确的顺序调用了 UART 发送?

用 Mock 可以这样写:

// mock_uart.c
static int g_call_count = 0;
static uint8_t g_last_byte = 0;

void Mock_UART_SendByte(uint8_t data) {
    g_call_count++;
    g_last_byte = data;
}

// 测试用例
void test_send_packet(void) {
    uint8_t packet[] = {0xAA, 0xBB, 0xCC};
    
    SendPacket(packet, 3);
    
    // 验证:调用了3次
    ASSERT_EQ(g_call_count, 3);
    // 验证:最后一次发送的是0xCC
    ASSERT_EQ(g_last_byte, 0xCC);
}

看到了吗?Mock 不光能返回值,还能记录行为。这是它和 Stub 最大的区别。

特性 Stub(桩) Mock(模拟)
返回值 固定预设值 可预设,也可动态
记录调用 不记录 记录次数、参数、顺序
验证行为 不能 可以验证「是否被调用」
适用场景 依赖函数必须返回一个值 需要验证交互行为
注意: 不要滥用 Mock。如果一个函数只是读取数据,用 Stub 就够了。只有当你需要验证「调用关系」时,才用 Mock。否则测试会变得又慢又脆弱。

1.4 测试驱动开发中的函数设计

测试驱动开发(TDD)对函数设计提出了更高的要求。我刚开始做 TDD 时,最大的感受是:函数必须「可测试」

什么叫可测试?说白了就是:

  • 函数不要依赖全局变量(或者至少能注入)
  • 函数不要直接调用硬件(通过接口抽象)
  • 函数的输入输出要清晰

举个例子,一个不好的设计:

// 不可测试
void ProcessData(void) {
    extern int g_sensor_value;  // 全局变量
    if (g_sensor_value > 100) {
        UART_SendString("ALARM");  // 直接调用硬件
    }
}

改成可测试的设计:

// 可测试
void ProcessData(int sensor_value, void (*send_func)(const char*)) {
    if (sensor_value > 100) {
        send_func("ALARM");
    }
}

你看,现在我可以传一个桩函数进去,也可以传一个模拟函数进去。测试变得非常灵活。

TDD 的函数设计原则:
  • 依赖注入:把外部依赖作为参数传进来
  • 单一职责:一个函数只做一件事
  • 接口抽象:用函数指针或接口结构体隔离硬件

1.5 知识体系总览

下面这张图,是我自己整理的一个函数测试知识体系。它把 Stub、Mock 和 TDD 的关系串在了一起:

函数单元测试 Stub(桩) 返回固定预设值 让代码能编译通过 不记录调用行为 Mock(模拟) 记录调用次数和参数 验证交互行为 可动态设置返回值 TDD 函数设计 依赖注入 接口抽象 单一职责 Stub 解决「跑不起来」的问题 Mock 解决「测不准」的问题,TDD 解决「不好测」的问题

1.6 避坑指南

最后,分享几个我踩过的坑:

  • 不要用 Mock 模拟所有东西。 我曾经在一个项目里,把 80% 的测试代码都写成了 Mock,结果每次改接口都要改几十个测试文件。后来我学乖了——只 Mock 外部依赖,内部函数用真实实现。
  • 桩函数要简单。 我见过有人把桩函数写得比真实函数还复杂,里面加了一堆条件判断。这完全违背了桩的初衷。桩就是「假人」,越简单越好。
  • 测试代码也是代码。 很多人写完测试就不管了,结果测试代码比生产代码还难维护。我个人习惯:测试代码和生产代码一样,要 review、要重构、要写注释。
一个小技巧: 在嵌入式项目中,我经常用预处理宏来切换真实函数和桩函数:
#ifdef UNIT_TEST
#define I2C_Read Stub_I2C_Read
#endif
这样不用改生产代码,测试时自动替换。

好了,关于函数桩和模拟,今天就聊到这里。记住一句话:测试不是为了证明代码没错,而是为了让你有信心改代码。Stub 和 Mock 就是给你这份信心的工具。


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