单元测试基础概念:被测单元、桩函数、驱动函数、测试框架
好,咱们正式开始聊单元测试的核心概念。说实话,我见过不少工程师,写了三五年嵌入式代码,一提到单元测试还是懵的。不是他们不聪明,而是这些概念在学校里讲得太虚了。今天我用实战的视角,把这些东西掰开揉碎了讲清楚。
什么是“被测单元”?
被测单元,英文叫 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);
}
}
驱动函数和桩函数的关系,我画了张图,你看一眼就明白了:
这张图我建议你保存下来。每次写测试的时候,先想想:谁是驱动?谁是被测单元?谁需要打桩?想清楚了再动手。
测试框架:别自己造轮子
测试框架,英文叫 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 看结果?
我个人觉得,理解这些概念最好的方式就是动手写一个。哪怕是最简单的加法函数,也按这个流程走一遍:写被测函数、写桩函数、写驱动函数、用框架跑起来。走完一遍,你就知道这些东西到底是干什么用的了。