2. 嵌入式单元测试基础

好,咱们正式开始聊单元测试的核心概念。说实话,我见过不少工程师,写了三五年嵌入式代码,一提到单元测试还是懵的。不是他们不聪明,而是这些概念在学校里讲得太虚了。今天我用实战的视角,把这些东西掰开揉碎了讲清楚。

什么是“被测单元”?

被测单元,英文叫 Unit Under Test,简称 UUT。说白了,就是你这次要测试的最小代码块。

在嵌入式 C 语言里,它通常是一个函数。但注意,不是随便一个函数都行。我个人的习惯是:一个被测单元只做一件事。比如一个计算校验和的函数,或者一个解析 CAN 报文的函数。

关键判断标准:

  • 这个函数能否独立编译?
  • 它的输入输出是否清晰?
  • 它有没有外部依赖(硬件寄存器、全局变量、其他函数)?

举个例子。你写了一个温度传感器驱动,里面有个 read_temperature() 函数。它调用了 i2c_read()。那么被测单元是 read_temperature(),而不是整个驱动文件。为什么?因为 i2c_read() 可能还没写好,或者它依赖硬件。我们得把它隔离掉。

我的经验: 刚开始做单元测试时,总想把一个文件里所有函数一起测。结果出了问题根本定位不到是哪个函数错了。后来我学乖了——一次只测一个函数,测完一个再测下一个。虽然慢,但心里踏实。

桩函数:让被测单元“孤立”起来

桩函数,英文叫 Stub。这是单元测试里最核心、也最容易让人头疼的概念。

什么是桩函数?它是一个假的函数,用来替换被测单元调用的真实函数。

为什么要这么做?因为真实函数可能有副作用:

  • 它可能读写硬件寄存器
  • 它可能发送网络报文
  • 它可能操作文件系统
  • 它可能依赖另一个还没写完的模块

这些在单元测试环境里都跑不了。所以我们需要一个“替身”。

来看一个实际例子。假设被测函数是 calculate_fuel_consumption(),它内部调用了 get_engine_speed():

// 原始代码
float calculate_fuel_consumption(void) {
    uint16_t speed = get_engine_speed();  // 依赖硬件
    // ... 计算逻辑
    return result;
}

// 桩函数
uint16_t get_engine_speed(void) {
    return 3000;  // 固定返回 3000 RPM
}

你看,桩函数不读硬件,直接返回一个固定值。这样我们就可以专心测试 calculate_fuel_consumption() 的计算逻辑了。

我曾经踩过的坑: 写桩函数时,只返回了一个“正常值”。结果被测函数里有个除以零的 bug,因为桩函数从来没返回过 0。后来我养成了一个习惯——桩函数要覆盖边界值:最小值、最大值、异常值,至少各写一个测试用例。

驱动函数:测试的“总指挥”

驱动函数,英文叫 Test Driver。它的角色很简单:调用被测单元,传入参数,检查结果。

你可以把它理解成一个临时的 main() 函数。只不过这个 main() 不是启动整个系统,而是专门用来跑测试的。

一个典型的驱动函数长这样:

void test_calculate_fuel_consumption(void) {
    // 1. 准备输入
    // 2. 设置桩函数返回值
    // 3. 调用被测函数
    float result = calculate_fuel_consumption();
    // 4. 检查结果
    if (result > 0.0f && result < 100.0f) {
        printf("PASS: result = %.2f\n", result);
    } else {
        printf("FAIL: result = %.2f\n", result);
    }
}

驱动函数和桩函数的关系,我画了张图,你看一眼就明白了:

单元测试核心结构图 驱动函数 被测单元 (UUT) 桩函数 A 桩函数 B 桩函数 C 驱动函数调用被测单元,被测单元调用桩函数。桩函数隔离外部依赖,让测试只关注被测单元自身的逻辑。 箭头方向 = 调用方向。驱动 → 被测单元 → 桩函数。

这张图我建议你保存下来。每次写测试的时候,先想想:谁是驱动?谁是被测单元?谁需要打桩?想清楚了再动手。

测试框架:别自己造轮子

测试框架,英文叫 Test Framework。它是一套现成的工具,帮你管理测试用例、执行测试、生成报告。

嵌入式领域常用的测试框架有这些:

框架名称 语言 特点 适用场景
CUnit C 轻量,纯 C 实现 资源受限的嵌入式平台
Unity C 极简,只有几个文件 MCU 裸机项目
CMock C 自动生成桩函数 需要大量桩函数的项目
CppUTest C/C++ 功能全面,支持 C++ 大型嵌入式 Linux 项目
Google Test C++ 生态丰富,社区活跃 资源充足的嵌入式 Linux

我的建议: 新手从 Unity 开始。它只有 3 个文件,半小时就能跑起来。别一上来就上 CMock,那玩意儿配置起来能让你怀疑人生。等你把桩函数的手写逻辑搞明白了,再用自动生成工具不迟。

测试框架能帮你做什么?我列几个最常用的:

  • 断言宏: 比如 TEST_ASSERT_EQUAL(expect, actual),一行代码就能做比较
  • 测试分组: 把相关的测试用例放在一个组里,方便管理
  • 自动运行: 写一个 RUN_TEST() 就能执行所有测试
  • 结果统计: 自动告诉你通过了几个、失败了几个

举个例子,用 Unity 写一个测试:

#include "unity.h"
#include "fuel_consumption.h"

// 桩函数
uint16_t get_engine_speed(void) {
    return 3000;
}

void test_calculate_fuel_consumption_normal(void) {
    float result = calculate_fuel_consumption();
    TEST_ASSERT_TRUE(result > 0.0f);
    TEST_ASSERT_TRUE(result < 100.0f);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_calculate_fuel_consumption_normal);
    return UNITY_END();
}

你看,代码量不大,但结构很清楚。驱动函数是 main(),被测单元是 calculate_fuel_consumption(),桩函数是 get_engine_speed()。框架负责执行和报告。

核心要点总结:

  • 被测单元: 最小可测试代码块,通常是一个函数
  • 桩函数: 替换真实依赖的“替身”,控制输入、隔离外部
  • 驱动函数: 测试的入口,负责调用和验证
  • 测试框架: 提供断言、运行、报告等基础设施

嗯,这四个概念是单元测试的基石。你想想看,没有桩函数,你怎么测那些依赖硬件的代码?没有驱动函数,谁来调用被测单元?没有框架,你难道每次手动写 printf 看结果?

我个人觉得,理解这些概念最好的方式就是动手写一个。哪怕是最简单的加法函数,也按这个流程走一遍:写被测函数、写桩函数、写驱动函数、用框架跑起来。走完一遍,你就知道这些东西到底是干什么用的了。