7、模拟与桩函数:为什么需要模拟、手写桩函数、链接时替换

单元测试里有个绕不开的话题——依赖

你写的函数,很少是孤岛。它可能要调用别的模块,可能要读写硬件寄存器,可能要访问文件系统。这些外部依赖,在单元测试环境里往往跑不起来。怎么办?

答案就是:模拟(Mock)桩函数(Stub)

为什么需要模拟?

说白了,模拟就是为了隔离。我只想测我写的这个函数逻辑对不对,不想管它调用的那个函数有没有bug,更不想让测试跑到硬件上去。

我遇到过最典型的场景:测一个温度采集模块。它内部调用了read_adc(),这个函数会去操作真实的ADC寄存器。在PC上做单元测试时,根本没有这个硬件。如果不模拟,测试根本跑不起来。

模拟能带来几个好处:

  • 可控性:你可以让桩函数返回任何你想要的值,比如正常值、边界值、错误码。
  • 可重复性:每次测试结果都一样,不会因为硬件状态不同而飘忽不定。
  • 速度:模拟的调用是纳秒级的,比真实硬件快几个数量级。
  • 覆盖异常路径:你想测“ADC读取失败”这个分支,真实硬件很难触发。但桩函数直接返回-1,轻松搞定。
核心原则:只测自己的代码,不测别人的代码。模拟就是帮你把“别人的代码”替换掉。

手写桩函数

最原始也最灵活的方式,就是自己写桩函数。

假设被测函数长这样:

// 被测模块:temperature.c
#include "adc.h"

float get_temperature(void) {
    int raw = read_adc(ADC_CHANNEL_1);
    if (raw < 0) {
        return -1.0f;  // 错误
    }
    return (float)raw * 0.125f - 50.0f;
}

它依赖了adc.h里的read_adc()。在测试文件里,我直接写一个桩函数:

// test_temperature.c
#include "temperature.h"

// 桩函数:模拟ADC读取
static int mock_adc_value = 0;

int read_adc(int channel) {
    // 忽略channel参数,直接返回预设值
    return mock_adc_value;
}

void test_get_temperature_normal(void) {
    mock_adc_value = 400;  // 模拟正常值
    float temp = get_temperature();
    // 期望:400 * 0.125 - 50 = 0.0
    assert(temp == 0.0f);
}

void test_get_temperature_error(void) {
    mock_adc_value = -1;   // 模拟错误
    float temp = get_temperature();
    assert(temp == -1.0f);
}

嗯,这里要注意:桩函数必须和原函数有完全相同的签名。返回值、参数类型、参数个数,一个都不能差。否则链接时会报错,或者更糟——静默地调用到错误函数。

我的习惯:桩函数里加个静态变量来控制返回值。这样测试用例之间不会互相干扰,每个用例开始前重新设置一下就行。

链接时替换

手写桩函数虽然简单,但有个问题:你得把桩函数和被测代码编译到一起,并且确保链接器优先找到你的桩函数,而不是真正的实现。

这就是链接时替换的技巧。

具体做法分三步:

  1. 编译被测模块:正常编译temperature.c,但不链接。
  2. 编译桩函数:把test_temperature.c(里面包含了桩函数)也编译成目标文件。
  3. 链接测试程序:把两个目标文件链接在一起。链接器看到read_adc有两个定义(一个在adc.c,一个在桩函数里),它会优先选择第一个找到的。只要我们把桩函数的目标文件放在前面,它就赢了。

用命令行演示一下:

# 编译被测模块
gcc -c temperature.c -o temperature.o

# 编译测试文件(包含桩函数)
gcc -c test_temperature.c -o test_temperature.o

# 链接:桩函数目标文件在前,替换掉真正的adc.o
gcc test_temperature.o temperature.o -o test_runner -lcunit

你看,根本没有链接adc.o。链接器找不到真正的read_adc?不,它找到了桩函数里的那个,完美替换。

我曾经踩过的坑:有一次我忘了把桩函数的目标文件放在前面,结果链接器用了真正的read_adc,测试直接去操作硬件了,把开发板上的ADC寄存器写乱了。从那以后,我每次都在Makefile里显式控制链接顺序,绝不含糊。

三种模拟方式的对比

方式 优点 缺点 适用场景
手写桩函数 完全可控,无外部依赖 每个依赖都要手写,工作量大 依赖少,或需要精细控制返回值
链接时替换 无需修改源代码,纯编译技巧 需要管理链接顺序,容易出错 已有现成代码,不想改动
Mock框架 自动化生成桩函数,功能强大 学习成本高,可能引入框架bug 大型项目,依赖多

我个人更推荐手写桩函数 + 链接时替换的组合。你想想看,嵌入式项目里依赖通常就那么几个——硬件抽象层、外设驱动、OS接口。手写几个桩函数花不了多少时间,但换来的是完全透明的测试逻辑。

知识体系总览

下面这张图,把本章的核心逻辑串起来了:

模拟与桩函数核心逻辑 被测函数 真实依赖(硬件/文件) 桩函数(模拟实现) 链接时替换 手写桩函数 链接时替换 Mock框架 核心目标:隔离外部依赖,让单元测试只关注被测函数自身的逻辑 ← 替换方向:真实依赖 → 桩函数 →

说白了,模拟和桩函数就是给被测函数搭一个安全可控的测试环境。你不需要真的去操作硬件,也不需要真的去读写文件。你只需要告诉桩函数:“这次你给我返回0,下次你给我返回-1。” 然后看被测函数怎么处理这些返回值。

我在项目中见过不少同事,一开始觉得手写桩函数麻烦,跑去用复杂的Mock框架。结果框架配置比测试代码还长,出了问题都不知道是框架的bug还是自己的bug。后来大家都回归到最朴素的方式——手写桩函数,链接时替换。简单、直接、可控。

避坑指南:如果你用链接时替换,记得在测试文件里加个编译守卫。比如#ifdef UNIT_TEST,只在测试编译时启用桩函数,正常编译时还是用真实实现。这样一份代码,两种用途,不会乱。

嗯,关于模拟和桩函数,核心就这些。记住:隔离依赖,控制输入,验证输出。能做到这三点,你的单元测试就已经成功了一大半。


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