4. 单元测试核心概念

做嵌入式单元测试,最头疼的是什么?

不是被测代码本身有多复杂,而是它依赖的东西太多——硬件寄存器、外设驱动、RTOS 的 API、甚至另一块芯片的通信协议。你想想看,如果测一个温度传感器驱动,每次跑测试都得真的接个传感器上去,那还叫单元测试吗?

所以,我们需要「替身」。在测试领域,这些替身有个专门的名字——测试替身(Test Double)。而其中最常用的两种,就是桩函数(Stub)和模拟对象(Mock)。

一句话区分:桩函数负责「给答案」,模拟对象负责「查行为」。前者关注返回值,后者关注调用过程。

4.1 什么是桩函数(Stub)?

桩函数,说白了就是一个「假函数」。它替换掉真实依赖,返回你预先设定好的数据。

我在项目中遇到过这样一个场景:测一个电池电量计算模块,它内部调用了 adc_read() 来获取电压值。真实硬件上,ADC 读回来的值受温度、噪声影响,每次都不一样。这怎么测?

我的做法很简单——写个桩函数:

// 真实函数(硬件相关)
int adc_read(uint8_t channel) {
    // 操作ADC寄存器,等待转换完成...
    return HAL_ADC_GetValue(&hadc);
}

// 桩函数(测试用)
int adc_read(uint8_t channel) {
    // 不管channel是什么,都返回固定值
    return 2048;  // 对应3.3V输入的一半
}

这样,被测模块就以为 ADC 读到了 2048,然后去算电量百分比。我可以控制这个返回值,验证不同电压下的逻辑是否正确。

桩函数的核心特征:

  • 返回值由测试用例硬编码或通过参数传入
  • 不关心被调用了多少次、什么顺序
  • 通常没有副作用(不写文件、不发消息)
  • 适合用来隔离「查询型」依赖(读传感器、读配置、读状态)

4.2 什么是模拟对象(Mock)?

模拟对象比桩函数「高级」一点。它不仅提供返回值,还会记录调用信息——谁调了它、传了什么参数、调了几次、顺序对不对。

为什么会需要这个?

我记得有一次测一个通信协议栈,上层调用 send_packet() 发送数据,底层驱动应该先发一个同步头,再发数据包,最后发校验。如果底层驱动没按这个顺序来,整个通信就会乱掉。这时候,光靠桩函数返回「发送成功」是不够的,我得知道它到底怎么发的。

// 模拟对象示例(使用CppUMock风格)
TEST(CommTest, ShouldSendSyncThenDataThenChecksum) {
    // 设定预期:先调send_byte(0xAA),再调send_byte(0x55),最后调send_byte(0x01)
    mock().expectOneCall("send_byte").withIntParameter("data", 0xAA);
    mock().expectOneCall("send_byte").withIntParameter("data", 0x55);
    mock().expectOneCall("send_byte").withIntParameter("data", checksum);
    
    // 执行被测函数
    send_packet(test_data);
    
    // 验证:所有预期调用都已发生(CppUMock在析构时自动验证)
    mock().checkExpectations();
}

看到区别了吗?模拟对象在测试开始前就「预定」了行为,测试结束后再「对账」。如果实际调用和预期不符,测试就失败。

注意:模拟对象容易让测试变得脆弱。如果实现细节稍微变一下(比如优化时合并了两次发送),测试就挂了。我个人建议:只在验证「交互顺序」和「调用次数」很重要时才用 Mock,否则用 Stub 就够了。

4.3 桩 vs 模拟:一张表说清楚

维度 桩函数(Stub) 模拟对象(Mock)
关注点 返回值 调用行为
验证方式 断言返回值 验证调用次数、参数、顺序
状态管理 通常无状态 可记录调用历史
适用场景 读取传感器、获取配置、查询状态 发送命令、触发事件、协议交互
实现复杂度 低(几行代码) 中高(需要框架或手动记录)
测试脆弱性 低 中高(实现细节变化易挂)

4.4 手动编写桩 vs 自动生成

好,现在问题来了:这些桩和模拟,是手写还是用工具生成?

两种我都用过,各有各的坑。

4.4.1 手动编写桩

早期做嵌入式测试,我基本都是手写。比如给一个 I2C 驱动写桩:

// 手动桩:i2c_stub.c
static uint8_t mock_registers[256];
static bool write_protected = false;

int i2c_write(uint8_t dev_addr, uint8_t reg, uint8_t *data, uint16_t len) {
    // 只响应特定设备地址
    if (dev_addr != 0x50) return -1;
    // 模拟写保护
    if (write_protected && reg >= 0x10) return -2;
    memcpy(&mock_registers[reg], data, len);
    return 0;
}

int i2c_read(uint8_t dev_addr, uint8_t reg, uint8_t *data, uint16_t len) {
    if (dev_addr != 0x50) return -1;
    memcpy(data, &mock_registers[reg], len);
    return 0;
}

// 测试辅助函数
void i2c_stub_set_register(uint8_t reg, uint8_t value) {
    mock_registers[reg] = value;
}
void i2c_stub_set_write_protect(bool enable) {
    write_protected = enable;
}

优点:完全可控,没有黑盒,想怎么模拟就怎么模拟。

缺点:写起来累。一个稍微复杂点的外设,桩代码可能比被测代码还长。而且每个项目都得重新写一遍。

我的经验:手写桩适合「简单且稳定」的依赖。比如一个 GPIO 读写函数,手写几行就够了。但如果是 SPI Flash 驱动、文件系统这类复杂接口,手写桩会让人崩溃。

4.4.2 自动生成桩

后来我开始用工具。C 语言里比较成熟的有 CppUMock、CMock(配合 Unity 测试框架)、Fake Function Framework (FFF)。

以 FFF 为例,自动生成桩只需要几行声明:

// 使用FFF自动生成桩
#include "fff.h"

// 声明要模拟的函数
FAKE_VALUE_FUNC(int, adc_read, uint8_t);
FAKE_VOID_FUNC(gpio_set_pin, uint8_t, bool);
FAKE_VALUE_FUNC(int, i2c_write, uint8_t, uint8_t, uint8_t*, uint16_t);

TEST(AdcTest, ShouldReturnExpectedValue) {
    // 设置返回值
    adc_read_fake.return_val = 2048;
    
    // 执行测试
    int result = calculate_battery_level();
    
    // 断言
    ASSERT_EQ(result, 50);
    
    // 验证调用次数
    ASSERT_EQ(adc_read_fake.call_count, 1);
}

你看,三行宏定义就生成了三个完整的桩函数。每个桩还自动带了 call_count、arg_history 等记录功能。

对比维度 手动编写 自动生成
开发速度 慢(每个函数都要手写) 快(宏或脚本生成)
灵活性 高(可以模拟复杂行为) 中(受限于框架能力)
维护成本 高(接口变了要手动改桩) 低(重新生成即可)
代码可读性 好(桩逻辑清晰可见) 中(宏展开后不易调试)
适合场景 简单接口、特殊行为模拟 大量接口、频繁变更的依赖

4.5 知识体系:模拟与桩的核心逻辑

下面这张图,是我自己总结的「测试替身选择决策树」。每次做单元测试前,我都会过一遍:

测试替身选择决策树 被测函数有外部依赖? 需要验证调用行为? 使用 Mock 对象 使用 Stub 桩函数 手动编写 vs 自动生成? 手动编写 vs 自动生成? 手动 Mock 自动 Mock(框架) 手动 Stub 自动 Stub(FFF等) 决策原则 简单依赖 → 手动;复杂/多变依赖 → 自动;验证交互 → Mock;只取数据 → Stub

4.6 避坑指南:我曾经踩过的坑

做了这么多年测试,在桩和模拟上栽过的跟头不少。分享几个典型的:

  • 桩函数太「聪明」:我曾经写过一个桩,里面塞了完整的业务逻辑。结果被测代码有 bug,桩也有 bug,两个 bug 互相抵消,测试全绿。上线就崩。后来我规定:桩函数里最多三行代码,超过就说明设计有问题。
  • Mock 验证过度:有一次我 Mock 了一个日志打印函数,连调用了多少次、传了什么字符串都验证。后来重构时把两条日志合并成一条,测试挂了。其实日志内容对不对根本不重要。记住:只验证你关心的行为。
  • 自动生成桩的链接问题:用 FFF 时,如果被测文件里 #include 了真实头文件,而链接器又先找到了真实函数,桩就不会生效。我习惯在测试文件里用 #define 把真实函数名替换掉,或者用链接器的 --wrap 选项。

我的个人建议:

  • 新手从手动 Stub 开始,先理解「替身」的概念
  • 等测试多了,引入 FFF 或 CMock 提升效率
  • 只在验证「交互协议」和「调用顺序」时用 Mock
  • 永远不要让桩函数包含业务逻辑

嗯,关于桩和模拟,核心就是这些。记住一个原则:测试替身是为了让被测代码「孤立」出来,不是为了模拟整个世界。你替掉的依赖越少,测试的可信度就越高。