4. 测试桩与模拟对象:手动编写桩函数 vs 自动生成模拟对象

做嵌入式单元测试,你迟早会撞上一个绕不开的问题:被测函数调了外部依赖,比如读传感器、写寄存器、调用另一个模块的接口。这时候怎么办?

我早年做车载控制器测试时,就吃过这个亏。被测代码里调了一个底层CAN驱动,我傻乎乎地连上真实硬件跑测试。结果一次误操作,直接把测试台的电源模块烧了。嗯,从那以后我学乖了——测试桩和模拟对象,必须用起来。

4.1 为什么需要测试桩和模拟对象?

说白了,嵌入式代码的依赖关系太复杂了。你测一个模块,它背后可能链着一串硬件、驱动、OS服务。如果每次都跑全链路,测试就变成了集成测试,速度慢、不稳定、还容易出事故。

测试桩(Stub)和模拟对象(Mock)就是用来替换真实依赖的。它们让你能隔离被测单元,只测你想测的逻辑。

核心原则: 被测单元之外的,都用桩或模拟对象替代。测试只关心“我的代码逻辑对不对”,不关心“别人的代码和硬件好不好”。

4.2 手动编写桩函数

手动桩是最原始、也是最可控的方式。我在早期项目里,几乎全靠手写桩。

举个例子,被测函数调用了 HAL_ADC_GetValue(),你写一个桩:

// 被测模块:adc_reader.c
uint32_t read_adc_average(void) {
    uint32_t sum = 0;
    for (int i = 0; i < 10; i++) {
        sum += HAL_ADC_GetValue();
    }
    return sum / 10;
}

// 测试桩:stub_adc.c
static uint32_t stub_value = 0;

uint32_t HAL_ADC_GetValue(void) {
    return stub_value;
}

void set_adc_stub_value(uint32_t val) {
    stub_value = val;
}

手动桩的好处很明显:

  • 完全可控——你想让它返回什么就返回什么
  • 无外部依赖——一个.c文件搞定,不依赖任何框架
  • 调试方便——出问题了直接看桩代码,逻辑一目了然

但缺点也突出:

  • 重复劳动——每个依赖都要写一套桩,项目大了能写几百个
  • 维护成本高——接口变了,所有桩都得跟着改
  • 功能有限——很难做复杂的调用次数校验、参数捕获

我的经验: 手动桩适合接口稳定、数量少(10个以内)的场景。比如你只测一个模块,它只依赖2-3个底层函数,手写桩是最快的。

4.3 自动生成模拟对象

当项目规模上来后,手动桩就扛不住了。我记得有个项目,被测模块依赖了30多个外部接口,手写桩文件加起来快2000行。每次接口变更,改桩改到崩溃。

这时候就需要自动生成模拟对象。常用的工具有:

工具/框架 语言 特点
CMock C 基于Unity测试框架,自动生成Mock函数
Fake Function Framework (FFF) C 轻量级,宏定义实现,无需代码生成
CppUMock C++ CppUTest配套,支持C和C++
Google Mock C++ 功能强大,但需要C++编译器

以FFF为例,它用宏定义自动生成模拟对象:

// 使用FFF框架
#include "fff.h"

// 声明要模拟的函数
FAKE_VALUE_FUNC(uint32_t, HAL_ADC_GetValue);

// 测试代码
void test_read_adc_average(void) {
    // 设置返回值序列
    HAL_ADC_GetValue_fake.return_val_seq_len = 10;
    HAL_ADC_GetValue_fake.return_val_seq = (uint32_t[]){100, 102, 98, 101, 99, 100, 101, 99, 100, 102};
    
    // 调用被测函数
    uint32_t result = read_adc_average();
    
    // 验证结果
    TEST_ASSERT_EQUAL(100, result);
    
    // 验证调用次数
    TEST_ASSERT_EQUAL(10, HAL_ADC_GetValue_fake.call_count);
}

自动生成的优势:

  • 效率高——一行宏定义就生成一个完整的模拟对象
  • 功能丰富——自动记录调用次数、参数、返回值序列
  • 维护简单——接口变了,重新生成即可

但也不是没有代价:

  • 学习成本——团队成员需要掌握框架用法
  • 调试困难——出问题时,宏展开后的代码很难跟踪
  • 资源开销——自动生成的代码可能比手写桩大

我曾经踩过的坑: 在资源受限的MCU上跑单元测试,用了CMock自动生成大量模拟对象,结果代码体积膨胀到Flash装不下。后来发现是CMock为每个模拟函数生成了完整的参数校验和调用记录代码。解决方案是:对性能敏感的桩用手写,对逻辑复杂的桩用自动生成。

4.4 如何选择?

你想想看,到底用手写还是自动生成?我个人的决策框架是这样的:

  1. 接口数量 < 5,且稳定不变 → 手写桩,最快最轻
  2. 接口数量 5-20,或经常变更 → 自动生成,用FFF或CMock
  3. 需要校验调用顺序、参数匹配 → 自动生成,用Google Mock或CppUMock
  4. 资源极度受限(ROM < 64KB) → 手写桩,控制代码体积

下面这张图展示了两种方式的适用场景:

测试桩 vs 模拟对象:选择决策图 手动编写桩函数 适用场景 • 接口数量 < 5 • 接口稳定不变 • 资源极度受限 • 调试需要高透明度 自动生成模拟对象 适用场景 • 接口数量 > 5 • 接口频繁变更 • 需要调用次数校验 • 需要参数捕获 决策原则:优先保证测试的可维护性和可调试性 不要为了“自动化”而自动化,也不要为了“简单”而放弃效率 手动桩的陷阱 • 接口变更时容易漏改 • 难以模拟复杂行为 • 代码膨胀后维护困难 自动生成的陷阱 • 学习曲线陡峭 • 调试时宏展开难跟踪 • 资源开销可能过大

4.5 混合策略:我推荐的做法

在实际项目中,我很少只用一种方式。我的习惯是:

  • 底层硬件抽象层(HAL):手写桩。因为这些接口极少变化,而且需要精确控制返回值来模拟硬件异常。
  • 模块间调用:自动生成模拟对象。因为这些接口经常变更,而且需要校验调用顺序和参数。
  • OS服务(任务调度、信号量等):手写桩+少量宏辅助。OS接口稳定,但行为复杂,手写更可控。

一个小技巧: 在项目初期,先用自动生成框架快速搭建测试骨架。等接口稳定后,再把性能热点的手动替换成手写桩。这样既保证了开发效率,又控制了最终代码体积。

4.6 避坑指南

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

  • 坑1:桩函数没有模拟错误路径。 我曾经只让桩返回正常值,结果被测代码里的错误处理分支从来没被测试覆盖过。上线后硬件异常,代码直接崩溃。现在我的桩一定会模拟至少一种异常情况。
  • 坑2:自动生成模拟对象时,忘了重置状态。 FFF框架的模拟对象默认会累积调用次数。如果测试用例之间不重置,后面的用例会被前面的影响。我现在每个测试用例开始前都会调用 RESET_FAKE()。
  • 坑3:手写桩和真实接口签名不一致。 有一次重构接口,改了参数类型,但忘了更新桩文件。编译通过了(因为C语言隐式转换),但测试结果全错。现在我会在桩文件里加编译期断言,确保签名匹配。

嗯,测试桩和模拟对象这件事,说白了就是在可控性和效率之间找平衡。没有银弹,但理解了各自的优劣,你就能根据项目实际情况做出合理选择。