一、单元测试基础:什么是单元测试、为什么需要单元测试、单元测试与集成测试的区别
各位同学好,我是老李。在嵌入式这行摸爬滚打了十几年,今天咱们来聊聊单元测试。
说实话,我刚入行那会儿,对单元测试也是嗤之以鼻的。觉得写代码就够忙了,还搞什么测试?浪费时间。直到有一次,我在一个电机控制项目里,因为一个 if 条件写反了,导致整批电路板在产线上冒烟……嗯,从那以后,我再也不敢不写单元测试了。
1.1 什么是单元测试?
单元测试,说白了就是:把你写的每一小块代码,单独拎出来,验证它是不是按你的预期干活。
这里的“单元”,在C语言里通常指一个函数。比如你写了一个 int calc_crc(uint8_t *data, uint8_t len),单元测试就是给它喂几组不同的数据,看看算出来的CRC对不对。
核心要点:单元测试是开发者自己写的、自己跑的、自己维护的。不是测试团队的事。
我个人习惯把单元测试看作“代码的保险丝”。你想想看,一个函数如果连最基本的输入输出都保证不了,那集成到一起肯定炸。
1.2 为什么需要单元测试?
这个问题我问过很多刚入行的工程师。最常见的回答是:“为了找bug。” 对,但不全对。
我总结了三层原因,一层比一层深:
- 第一层:尽早发现bug,降低修复成本。 你在写函数的时候就测,发现bug改起来就几行代码。等集成到系统里再查,你可能要花一整天去定位。我在项目中遇到过,一个指针越界的bug,单元测试5分钟就揪出来了,集成测试愣是查了两天。
- 第二层:倒逼你写出更好的代码。 这其实是最有价值的一点。如果一个函数很难写单元测试,那它大概率设计得有问题——耦合太紧、职责不单一、全局变量满天飞。为了能测,你会不自觉地写出更模块化、更解耦的代码。
- 第三层:提供安全网,让你敢重构。 代码写久了总要改。没有单元测试,你改一行代码心里都发虚,生怕把别的地方搞崩了。有单元测试兜底,你可以放心大胆地优化。我曾经重构过一个通信协议栈,改了将近一半的代码,全靠单元测试帮我确认没改坏。
我的建议:不要等到项目快结束了才补单元测试。那时候你连函数逻辑都忘了,补起来又痛苦又没效果。最好是写完一个函数,顺手就把测试写了。
1.3 单元测试与集成测试的区别
很多新手容易把这两个搞混。我画了一张图,帮你理清思路:
我再给你打个比方:
- 单元测试就像检查每个螺丝钉是不是合格的。你拿卡尺量一下直径、螺纹对不对。
- 集成测试就像把螺丝钉拧到设备上,看看能不能固定住,会不会滑丝。
两者缺一不可。但顺序很重要:先做单元测试,再做集成测试。螺丝钉都不合格,你硬拧上去,设备迟早要出问题。
避坑指南:我曾经见过一个团队,集成测试跑不过,一群人围着查了两天,最后发现是一个工具函数里 memcpy 的第三个参数传错了。如果写了单元测试,这个bug在写函数的那天就能发现。所以,别跳过单元测试直接搞集成,那是给自己挖坑。
1.4 一个简单的单元测试例子
光说不练假把式。我给你看一个真实的单元测试代码片段。假设我们有这样一个函数:
// 计算两个整数的最大公约数
int gcd(int a, int b) {
if (a < 0) a = -a;
if (b < 0) b = -b;
while (b != 0) {
int temp = b;
b = a % b;
a = temp;
}
return a;
}
对应的单元测试可以这样写(使用Unity测试框架):
#include "unity.h"
#include "math_utils.h"
void setUp(void) {}
void tearDown(void) {}
void test_gcd_positive_numbers(void) {
TEST_ASSERT_EQUAL_INT(6, gcd(12, 18));
TEST_ASSERT_EQUAL_INT(1, gcd(7, 13));
TEST_ASSERT_EQUAL_INT(5, gcd(15, 25));
}
void test_gcd_with_zero(void) {
TEST_ASSERT_EQUAL_INT(12, gcd(12, 0));
TEST_ASSERT_EQUAL_INT(0, gcd(0, 0));
}
void test_gcd_negative_numbers(void) {
TEST_ASSERT_EQUAL_INT(6, gcd(-12, 18));
TEST_ASSERT_EQUAL_INT(6, gcd(12, -18));
TEST_ASSERT_EQUAL_INT(6, gcd(-12, -18));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_gcd_positive_numbers);
RUN_TEST(test_gcd_with_zero);
RUN_TEST(test_gcd_negative_numbers);
return UNITY_END();
}
你看,每个测试用例只测一件事。边界情况(0、负数)都覆盖到了。跑一下,如果所有断言都通过,那这个函数基本就稳了。
小技巧:写测试用例时,先想好“正常情况”和“边界情况”。正常情况验证基本功能,边界情况(比如空指针、0、最大值)最容易藏bug。我一般按 3:1 的比例分配,3个正常用例配1个边界用例。
1.5 嵌入式环境下的特殊考量
做嵌入式C语言的单元测试,和做PC端软件不太一样。有几个坑你得注意:
- 硬件依赖:你的函数可能直接操作寄存器、GPIO、ADC。这时候需要写“桩函数”或“mock对象”,把硬件调用替换成模拟的。我习惯在测试文件里用
#ifdef UNIT_TEST来隔离硬件代码。 - 资源限制:嵌入式芯片的RAM和Flash都很小,跑不了完整的测试框架。我的做法是在PC上(Linux/Windows)用GCC编译测试,模拟嵌入式环境。交叉编译只在最终集成时做。
- 实时性:有些函数依赖定时器或中断。测试时需要用模拟时钟来控制时间流逝,否则测试结果不可重复。
曾经我踩过的坑:在一个RTOS项目里,我写了一个任务间通信的模块。单元测试在PC上全过,烧到板子上就死机。查了半天,发现是PC上 malloc 成功率高,而板子上堆空间不够。从那以后,我养成了在测试里加上“内存分配失败”的用例。
1.6 什么时候不该写单元测试?
说了这么多单元测试的好处,我也得说点实话。有些场景下,写单元测试确实不划算:
| 场景 | 建议 | 原因 |
|---|---|---|
| 一次性脚本、原型验证 | 不写 | 代码用完就扔,不值得投入 |
| UI/显示驱动层 | 少写 | 变化频繁,且难以自动化验证视觉效果 |
| 与硬件紧密耦合的底层驱动 | 写桩函数测逻辑 | 硬件行为不可模拟时,测核心算法逻辑即可 |
| 核心算法、协议栈、业务逻辑 | 必须写 | 这些出bug代价最大,测试收益最高 |
说白了,单元测试不是银弹。你得判断投入产出比。但如果你问我,我会说:凡是你能独立编译运行的函数,都值得写单元测试。
好了,这一章就聊到这儿。单元测试的概念和重要性,你应该心里有数了。下一章咱们会深入讲怎么搭建测试环境、选什么框架、怎么写第一个测试用例。到时候我会手把手带你跑通一个完整的例子。
记住一句话:写代码不写测试,就像开车不系安全带——平时没事,出事就是大事。
本章要点回顾:
- 单元测试是验证单个函数/模块的正确性
- 它能尽早发现bug、倒逼好设计、提供重构安全网
- 单元测试和集成测试是互补关系,先做单元再做集成
- 嵌入式环境下要注意硬件依赖、资源限制、实时性
- 不是所有代码都需要单元测试,核心逻辑必须测
公众号:蓝海资料掘金营,微信deep3321