单元测试:测试框架选择、测试用例设计、边界值测试、Mock对象使用
单元测试这件事,我做了十几年,踩过的坑比写过的代码还多。说实话,很多嵌入式工程师对单元测试的态度是「知道很重要,但就是懒得写」。我完全理解——嵌入式环境复杂,硬件依赖多,跑个测试还得搭平台。但我要告诉你,不写单元测试的代价,往往比写测试的成本高出一个数量级。
今天我们就来聊聊,怎么在C语言嵌入式项目里,把单元测试真正落地。不是那种「为了测试而测试」的形式主义,而是能帮你提前发现bug、重构时心里有底的那种。
测试框架怎么选?
选测试框架,说白了就是选一个「能让你舒服地写测试」的工具。我个人习惯用CUnit和Unity,这两个在嵌入式圈子里用得最多。
| 框架 | 特点 | 适用场景 |
|---|---|---|
| CUnit | 功能全面,支持断言、测试套件、报告生成 | 桌面端开发、Linux环境 |
| Unity | 轻量级,纯C实现,无依赖 | 嵌入式裸机、资源受限环境 |
| CMock | 配合Unity使用,自动生成Mock | 需要大量Mock的场景 |
我建议初学者从Unity入手。为什么?因为它简单到令人发指——就一个.c和一个.h文件,往项目里一丢就能用。我在一个STM32项目里用过,编译出来才几KB,完全不占资源。
核心原则:测试框架不是越强大越好,而是越「适合你的项目」越好。资源受限的MCU项目,别用CUnit那种重量级框架。
测试用例设计:别只测「正常路径」
很多新手写测试,只测「输入合法参数,得到正确结果」这条路径。这远远不够。我见过一个项目,所有单元测试都通过了,结果集成时一跑就崩——因为没人测过「参数为NULL」的情况。
测试用例设计,我一般遵循等价类划分和边界值分析这两个方法。
等价类划分
把输入数据分成若干「等价类」,每个类里选一个代表值来测。比如一个函数接受0-100的整数,那我们可以分成:
- 有效等价类:0-100之间的值
- 无效等价类:小于0的值
- 无效等价类:大于100的值
每个等价类选一个值测就行了,不用每个值都测。这能大幅减少测试用例数量。
边界值测试
边界值测试,说白了就是「在边界上反复横跳」。我遇到过最经典的bug,是一个温度传感器驱动,在0°C时返回了错误值。为什么?因为代码里写的是 if (temp > 0),而不是 if (temp >= 0)。边界值没测到,上线就出问题。
注意:边界值测试要覆盖「上边界、下边界、边界附近的值」。比如范围是0-100,那你要测-1、0、1、99、100、101这六个值。
Mock对象:把「依赖」隔离掉
嵌入式代码最头疼的就是硬件依赖。你写了个I2C驱动函数,要测它,总不能每次都接个真实传感器吧?这时候就需要Mock对象。
Mock说白了就是「假函数」——它模拟真实硬件的行为,但由你控制返回值。比如:
// 真实函数
int read_sensor(int addr) {
// 通过I2C读取硬件
return i2c_read(addr);
}
// Mock函数
int __wrap_read_sensor(int addr) {
// 返回预设值,不碰硬件
if (addr == 0x50) return 25; // 模拟温度25°C
return -1;
}
在Unity里配合CMock使用,可以自动生成这些Mock函数。你只需要告诉它「我要Mock哪个函数」,它就能生成一个可控制的替身。
小技巧:用链接器的wrap功能,可以在不修改原代码的情况下替换函数。编译时加 -Wl,--wrap=read_sensor,所有对read_sensor的调用都会自动转到__wrap_read_sensor。
一个完整的测试例子
我们来看一个实际案例。假设有个函数,计算电池剩余电量百分比:
// battery.c
int battery_level(int voltage_mv) {
if (voltage_mv < 3000) return 0; // 欠压
if (voltage_mv > 4200) return 100; // 过压
return (voltage_mv - 3000) * 100 / 1200;
}
用Unity写测试:
#include "unity.h"
#include "battery.h"
void setUp(void) {}
void tearDown(void) {}
void test_battery_level_low(void) {
TEST_ASSERT_EQUAL(0, battery_level(2999));
TEST_ASSERT_EQUAL(0, battery_level(3000)); // 边界
}
void test_battery_level_high(void) {
TEST_ASSERT_EQUAL(100, battery_level(4200)); // 边界
TEST_ASSERT_EQUAL(100, battery_level(5000));
}
void test_battery_level_normal(void) {
TEST_ASSERT_EQUAL(50, battery_level(3600));
TEST_ASSERT_EQUAL(25, battery_level(3300));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_battery_level_low);
RUN_TEST(test_battery_level_high);
RUN_TEST(test_battery_level_normal);
return UNITY_END();
}
你看,边界值测试覆盖了3000和4200这两个关键点。如果代码里写的是 if (voltage_mv <= 3000),那测试就能立刻发现逻辑问题。
单元测试的知识体系
下面这张图,是我自己总结的单元测试核心逻辑。你一看就明白:
避坑指南
我曾经在一个项目里,测试覆盖率做到了90%以上,结果上线还是出了问题。后来一查,发现所有测试都用了同一个Mock返回值——根本没测到异常分支。这就是典型的「为了覆盖率而测试」。
几个我踩过的坑,你注意避开:
- Mock太「完美」:Mock函数永远返回成功,导致真实场景下的错误处理代码从来没被执行过。记得Mock里也要模拟失败情况。
- 忽略全局状态:嵌入式代码里很多全局变量,测试之间会互相影响。每个测试用例执行前,记得用setUp()重置状态。
- 测试和代码耦合太紧:测试用例里直接依赖了内部实现细节,导致重构时测试也跟着改。测试应该测「行为」,而不是「实现」。
我的习惯:每次提交代码前,先跑一遍所有单元测试。如果测试没通过,绝不提交。这个习惯帮我挡住了至少几十次线上事故。
单元测试这件事,说白了就是「花时间省时间」。前期多花点功夫写测试,后期调试和重构时就能省下大把时间。别等到出了问题再后悔——我见过太多项目,因为没写单元测试,最后代码改都不敢改,整个项目烂在手里。
嗯,今天就聊到这儿。记住:好的单元测试,是你代码的「安全带」。别嫌麻烦,系上它。