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=1a=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逐步深入」,工具上要「因地制宜」。我个人习惯在项目初期就搭好测试框架,边写代码边写测试。别等到代码写完了再补测试——那时候你连函数逻辑都忘了,补出来的测试质量可想而知。

下一章咱们聊聊集成测试,看看怎么把单元测试的成果串起来。嗯,到时候再细说。