2、单元测试基础:什么是单元测试、单元测试的价值、单元测试与调试的区别、测试驱动开发(TDD)简介

好,咱们开始聊单元测试。说实话,我见过不少嵌入式工程师,一听到「测试」两个字就皱眉头。觉得那是测试组的事,跟自己没关系。嗯,我以前也这么想。直到有一次,我在一个心电监护仪的项目里,因为一个滤波算法的边界条件没处理好,导致整机在特定心率下输出异常波形。那一次返工,让我彻底改变了看法。

什么是单元测试

单元测试,说白了就是「最小可测试单元」的验证。在嵌入式软件里,这个单元通常是一个函数,或者一个模块。你写了一个计算CRC校验的函数,那就写个测试用例,给它输入数据,看它算出来的CRC对不对。

我个人的习惯是:每个函数都要有对应的测试用例。哪怕这个函数只有三行代码。你想想看,三行代码出错的概率是小,但一旦出错,排查起来反而更费劲——因为太简单了,你根本不会怀疑它。

核心定义:单元测试是针对软件设计的最小单元(函数/模块)进行的正确性检验。它验证的是「这个单元是否按照设计规格工作」。

举个例子,一个简单的温度转换函数:

// 摄氏转华氏
float celsius_to_fahrenheit(float celsius) {
    return celsius * 9.0f / 5.0f + 32.0f;
}

单元测试怎么写?

void test_celsius_to_fahrenheit(void) {
    // 测试0°C
    assert(fabs(celsius_to_fahrenheit(0.0f) - 32.0f) < 0.001f);
    
    // 测试100°C
    assert(fabs(celsius_to_fahrenheit(100.0f) - 212.0f) < 0.001f);
    
    // 测试-40°C(摄氏和华氏相等的点)
    assert(fabs(celsius_to_fahrenheit(-40.0f) - (-40.0f)) < 0.001f);
    
    printf("所有温度转换测试通过!\n");
}

你看,就这么简单。三个断言,覆盖了正常值、边界值和特殊值。我在项目中遇到过很多次,就是因为这种「看起来不可能出错」的函数,在移植到不同平台时,因为浮点精度问题出了岔子。单元测试就是帮你抓住这些「不可能」的错误。

单元测试的价值

很多人觉得写单元测试浪费时间。我承认,刚开始确实慢。但它的价值,是在项目后期才真正体现出来的。

我总结了几点核心价值:

  • 早期发现缺陷:越早发现bug,修复成本越低。这是软件工程的基本常识。单元测试在编码阶段就能发现问题,而不是等到集成测试甚至临床验证时才暴露。
  • 保障重构安全:医疗设备软件经常要升级、改需求。没有单元测试,你改一行代码,心里都是悬的。有了测试,你可以放心大胆地重构——跑一遍测试就知道有没有改坏东西。
  • 文档即代码:好的单元测试本身就是一份活文档。它告诉你这个函数应该怎么用,输入输出是什么,边界条件有哪些。比看几百页的设计文档直观多了。
  • 符合IEC 62304要求:这个必须提。IEC 62304明确要求软件单元要进行验证。单元测试是满足这个要求最直接的手段。审计的时候,测试报告就是你的护身符。

我的经验:在医疗器械项目中,我建议把单元测试的覆盖率目标定在90%以上。不是所有代码都需要100%覆盖,但核心算法、安全关键函数必须全覆盖。我曾经在一个输液泵项目中,因为一个定时器溢出处理的函数没覆盖到,导致在连续运行72小时后出现剂量偏差。从那以后,我对边界条件的测试就格外上心。

单元测试与调试的区别

这个问题我经常被问到。很多新手觉得「我调试的时候不也能发现问题吗?干嘛还要写测试?」

嗯,这里面的区别其实很大。我打个比方:

  • 调试是你发现程序出问题了,然后拿着探针、仿真器去查原因。它是被动的、一次性的。你修好了这个bug,不代表下次不会出现类似的问题。
  • 单元测试是你主动去验证程序是否正确。它是预防性的、可重复的。你写一次测试,以后每次修改代码都能跑一遍。

说白了,调试是「救火」,单元测试是「防火」。你想想看,哪个更重要?

对比维度 单元测试 调试
触发时机 编码阶段主动执行 发现问题后被动进行
执行方式 自动化、可重复 手动、一次性的
验证范围 覆盖所有设计用例 只针对当前问题
长期价值 回归测试、保障重构 解决当前bug
IEC 62304合规 必须的验证手段 不能替代验证

注意:千万不要用调试来替代单元测试。我曾经见过一个团队,项目快交付了才发现一个底层驱动的问题,结果所有人花了一周时间调试。如果当初写了单元测试,这个问题在第一天就能发现。调试是工具,不是方法。单元测试是方法,不是工具。

测试驱动开发(TDD)简介

TDD,全称是Test-Driven Development,测试驱动开发。它的核心思想很简单:先写测试,再写代码。

具体流程是这样的:

  1. 红:先写一个失败的测试用例。因为这时候功能代码还没写,测试必然失败。
  2. 绿:写最简的代码让测试通过。不要想太多,够用就行。
  3. 重构:优化代码结构,同时保证测试仍然通过。

就这么三步,循环往复。每次只增加一点点功能,每次都有测试保驾护航。

举个例子,假设我们要实现一个「判断年份是否为闰年」的函数:

第一步,写测试:

void test_is_leap_year(void) {
    // 能被4整除但不能被100整除的是闰年
    assert(is_leap_year(2024) == true);
    
    // 能被400整除的是闰年
    assert(is_leap_year(2000) == true);
    
    // 不能被4整除的不是闰年
    assert(is_leap_year(2023) == false);
    
    // 能被100整除但不能被400整除的不是闰年
    assert(is_leap_year(2100) == false);
}

第二步,写最简代码让测试通过:

bool is_leap_year(int year) {
    if (year % 400 == 0) return true;
    if (year % 100 == 0) return false;
    if (year % 4 == 0) return true;
    return false;
}

第三步,重构:这个函数已经够简洁了,不需要重构。但如果是更复杂的逻辑,你可能会考虑提取子函数、优化判断顺序等。

TDD的核心价值:它强迫你在写代码之前先想清楚「我要实现什么」。这听起来简单,但实际做起来,你会发现它能帮你避免很多设计上的弯路。我在一个多参数监护仪的项目中,用TDD开发了心率变异性的分析算法。虽然前期慢了一些,但后期几乎没出过bug,这在医疗软件里是非常难得的。

当然,TDD不是银弹。在嵌入式开发中,有些场景不太适合TDD,比如硬件相关的驱动代码。但即便如此,你也可以把硬件抽象层剥离出来,对上层逻辑做TDD。我个人的建议是:核心算法、业务逻辑、状态机这些,尽量用TDD。硬件相关的,可以先用单元测试覆盖,但不一定非要先写测试。

好了,这一章的内容就到这里。下一章我们会深入聊「如何选择单元测试框架」,以及在嵌入式环境里怎么搭建测试环境。这些东西,都是我在实际项目中踩过坑之后总结出来的经验,希望对你有帮助。