3、软件单元测试:单元测试策略、白盒测试方法、单元测试工具
单元测试,说白了就是给代码做「体检」。我经常跟团队里的年轻人讲,别小看这一步,很多线上事故的根因,其实在单元测试阶段就能揪出来。今天咱们就聊聊单元测试的策略、白盒测试方法,还有几个趁手的工具。
3.1 单元测试策略:测什么、怎么测、测多细
单元测试不是把每个函数都测一遍就完事了。我个人习惯先问三个问题:
- 测什么?——核心逻辑、边界条件、异常路径
- 怎么测?——隔离依赖、模拟输入、验证输出
- 测多细?——覆盖率目标、测试粒度、执行频率
在医疗器械软件里,我建议采用「自底向上」的策略。先测最基础的函数模块,再逐步组合。为什么?因为底层函数一旦出错,上层测试全白费。我在项目中遇到过,一个简单的CRC校验函数没测透,结果集成测试时花了三天才定位到问题——嗯,从那以后我再也不敢跳过底层单元测试了。
核心原则:单元测试要快、要独立、要可重复。每个测试用例只测一个逻辑点,不要搞「大杂烩」。
3.2 白盒测试方法:从语句覆盖到MC/DC
白盒测试,就是看着代码写测试。你想想看,如果连代码逻辑都不清楚,怎么保证测试的充分性?这里我重点讲三种覆盖方法,也是医疗器械认证里最常被问到的。
3.2.1 语句覆盖
最简单的一种。要求每行可执行代码至少被执行一次。说白了就是「每条路都走一遍」。但注意,语句覆盖的漏检率很高。举个例子:
if (a > 0 && b > 0) {
result = a + b;
} else {
result = 0;
}
如果测试用例只覆盖了 a=1, b=1 和 a=0, b=0,语句覆盖是100%。但你能保证 a=1, b=0 这个分支没问题吗?不能。所以,语句覆盖只是底线。
我的经验:语句覆盖适合做「快速冒烟测试」,但别把它当成充分性的标准。医疗器械认证里,语句覆盖通常只是最低要求。
3.2.2 分支覆盖
也叫判定覆盖。要求每个判断的真假分支都至少执行一次。还是上面那个例子:
- 测试用例1:
a=1, b=1(真分支) - 测试用例2:
a=0, b=0(假分支)
分支覆盖就达标了。但这里有个坑——条件组合。分支覆盖只关心整个判断的结果,不关心每个子条件的取值。比如 a > 0 && b > 0,分支覆盖无法保证 a > 0 为假、b > 0 为真这种情况被测试到。
避坑指南:我曾经在一个呼吸机项目中,分支覆盖做到100%,但集成测试时还是出了问题。后来发现是某个子条件的短路逻辑没测到。所以,分支覆盖只能作为中间目标,别停在这里。
3.2.3 MC/DC覆盖
全称是 Modified Condition/Decision Coverage。这是医疗器械软件认证(比如IEC 62304)里最严格的覆盖标准。要求:
- 每个判断的每个子条件都独立影响判断结果
- 每个子条件都取到真和假
- 每个判断都取到真和假
还是那个例子:
if (a > 0 && b > 0) {
result = a + b;
} else {
result = 0;
}
要满足MC/DC,需要至少3个测试用例:
| 测试用例 | a > 0 | b > 0 | 结果 | 说明 |
|---|---|---|---|---|
| 1 | 真 | 真 | 真 | 两个条件都真 |
| 2 | 真 | 假 | 假 | b独立影响结果 |
| 3 | 假 | 真 | 假 | a独立影响结果 |
你看,MC/DC能保证每个子条件都「独立地」影响过结果。这在安全关键系统里非常重要。我记得有一次做输液泵的软件认证,审核员直接翻到MC/DC报告,逐条核对——嗯,那场面,至今难忘。
重要提示:IEC 62304对安全等级B和C的软件,明确要求MC/DC覆盖。别想着偷懒,审核员一眼就能看出来。
3.3 单元测试工具:CUnit、Unity、Google Test
工具选得好,测试事半功倍。我根据项目经验,把三个主流工具的特点整理了一下:
| 工具 | 语言 | 特点 | 适用场景 |
|---|---|---|---|
| CUnit | C | 轻量、标准C库、无依赖 | 嵌入式裸机、资源受限环境 |
| Unity | C/C++ | 极简、可移植、专为嵌入式设计 | MCU、RTOS环境 |
| Google Test | C++ | 功能丰富、断言强大、支持mock | Linux/Windows、复杂C++项目 |
3.3.1 CUnit:老牌工具,稳如磐石
CUnit是纯C写的,不依赖任何外部库。我在一个老式的血压计项目中用过它,跑在ARM7裸机上,一点问题没有。用法也很直观:
#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>
void test_add(void) {
CU_ASSERT(add(2, 3) == 5);
CU_ASSERT(add(-1, 1) == 0);
}
int main() {
CU_initialize_registry();
CU_pSuite suite = CU_add_suite("math_test", 0, 0);
CU_add_test(suite, "test_add", test_add);
CU_basic_run_tests();
CU_cleanup_registry();
return 0;
}
小技巧:CUnit的断言宏很丰富,但别滥用。我一般只用CU_ASSERT和CU_ASSERT_EQUAL,够用了。多了反而容易混淆。
3.3.2 Unity:嵌入式测试的「瑞士军刀」
Unity是ThrowTheSwitch团队的作品,专门为嵌入式环境优化。它只有几个文件,移植起来非常方便。我在一个基于FreeRTOS的监护仪项目中用过,效果很好。
#include "unity.h"
void setUp(void) {}
void tearDown(void) {}
void test_calc_pressure(void) {
TEST_ASSERT_EQUAL(100, calc_pressure(10, 10));
TEST_ASSERT_TRUE(calc_pressure(0, 0) == 0);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_calc_pressure);
return UNITY_END();
}
Unity还有一个好处——它和CMock配合,可以生成mock函数。对于依赖硬件外设的代码,这个功能简直是救星。
注意:Unity的断言失败后会直接跳转到测试结尾,不会继续执行当前测试函数。所以每个测试用例最好只测一个逻辑点,别写「大而全」的测试函数。
3.3.3 Google Test:功能强大,但有点重
Google Test(简称gtest)是C++项目的首选。它支持值参数化测试、死亡测试、mock框架(Google Mock)等高级功能。我在一个基于Linux的影像设备项目中用过,写测试用例非常爽:
#include <gtest/gtest.h>
TEST(MathTest, Add) {
EXPECT_EQ(5, add(2, 3));
EXPECT_EQ(0, add(-1, 1));
}
TEST(MathTest, Multiply) {
EXPECT_EQ(6, multiply(2, 3));
EXPECT_EQ(0, multiply(0, 5));
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
但gtest的缺点也很明显——它依赖C++标准库,对嵌入式环境不太友好。如果你在跑Linux的工控板上做测试,gtest是首选。但如果是裸机或RTOS,我建议用Unity。
我的建议:别盲目追求工具的功能多。嵌入式环境资源有限,选工具时先看「能不能跑起来」,再看「好不好用」。我见过有人非要在STM32上跑gtest,结果编译都过不了——何必呢?
3.4 小结
单元测试是软件质量的基石。策略上要「自底向上」,方法上要「从语句覆盖到MC/DC逐步深入」,工具上要「因地制宜」。我个人习惯在项目初期就搭好测试框架,边写代码边写测试。别等到代码写完了再补测试——那时候你连函数逻辑都忘了,补出来的测试质量可想而知。
下一章咱们聊聊集成测试,看看怎么把单元测试的成果串起来。嗯,到时候再细说。