Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5.4 单元测试——Ceedling与覆盖率报告

那个让你安心的百分比

早上九点,你在为一个sensor_filter函数写单元测试。需求文档说:传感器读数每100ms采样一次,取最近5次采样的滑动平均值作为滤波输出。如果某次采样值偏离均值超过50%,视为干扰尖峰,应丢弃并沿用上一次有效值。

看似简单的逻辑。但你在纸上先画出了状态机:正常跟踪状态、尖峰抑制状态、上电初始状态。三个状态,两个转换条件,七个边界条件。你深吸一口气,开始写测试。

TEST_ASSERT_FLOAT_WITHIN写了一遍又一遍。你mock了ADC读数的返回值顺序:[100, 102, 101, 999, 103]。那个999是模拟电磁干扰的尖峰。你断言滤波输出不应超过105。

你写了8个测试用例。跑了一遍:全绿。其中两个边界测试(单一样本、阶跃变化)和两个尖峰测试(单尖峰、连续尖峰)覆盖了滤波器的全部关键行为。你修了代码,再跑,仍然全绿。

然后你敲下make coverage。浏览器里打开HTML报告,sensor.c:100%行覆盖率。你实际跑出来的数据比教学预设更漂亮——全部代码路径都被测试触碰到了。

你靠在椅子上,看着屏幕上大片的绿色高亮。这感觉和编译通过不一样。编译通过是“代码写对了“,测试通过是“代码做对了“。那种确信的感觉,就像一堵墙在承重测试后没有裂缝一样踏实。

核心洞察:编译通过是“代码写对了“,测试通过才是“代码做对了“。 在纸上画出状态机、两个转换条件、七个边界条件,再用mock序列[100,102,101,999,103]里的999模拟EMI尖峰,这套动作把模糊的“应该没问题“变成可重复验证的确定性;TEST_ASSERT_FLOAT_WITHIN的容差断言精确锁定滤波行为,这正是单元测试与编译在认知层级上的本质区别。

Ceedling是什么

Ceedling是Unity测试框架的构建系统和Ruby封装。Unity是一个极简的C语言单元测试框架:只有一个头文件和一个C文件,专为嵌入式系统设计。Ceedling在Unity之上提供了:

  • 项目配置:通过project.yml定义源文件路径、测试路径、编译选项
  • Mock框架:CMock自动生成Mock函数,用于隔离被测模块的依赖
  • 覆盖率集成:内置gcov支持,通过gcovr一键生成HTML报告
  • 测试运行器:自动发现测试文件,编译、运行、报告

为什么不在嵌入式测试中用Google Test或CppUTest?因为Unity的设计哲学是“极简“:它不需要C++,不需要异常处理,不需要标准库支持。这意味着它可以直接在Cortex-R5这样的裸机核心上运行,只需要一个printf或semihosting通道来输出结果。

/* TEACHING: 在生产环境中,VectorCAST和Tessy是更常见的选择,因为它们提供了ISO 26262认证的测试工具链。但Unity/Ceedling的教学价值在于,它让你理解测试框架的最小必要机制。*/

核心洞察:Unity的“极简“是专为嵌入式定制的:不需要C++、不需要异常处理、不需要标准库,因此能在Cortex-R5这类裸机核心上直接运行。 Ceedling在Unity之上补齐项目配置、CMock自动生成Mock、gcov覆盖率集成与测试运行器,构成完整框架。它的教学价值在于让你看清测试框架的最小必要机制——而这正是VectorCAST、Tessy等商业工具在解决的本质问题。

走进eng-lite的测试结构

测试文件:tests/test_sensor.c

#include "unity.h"
#include "sensor.h"

/* setUp runs before every test — ensures clean state */
void setUp(void)
{
    sensor_init();
}

void tearDown(void) {}

/* @covers SR-001 */
void test_init_returns_zero(void)
{
    float val = sensor_read();
    TEST_ASSERT_EQUAL_FLOAT(0.0f, val);
}

/* @covers SR-002 */
void test_filter_average_normal(void)
{
    unsigned int i;
    for (i = 0u; i < 5u; i++)
    {
        sensor_filter(10.0f);
    }
    float val = sensor_read();
    TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, val);
}

/* @covers SR-002 */
void test_filter_rejects_single_spike(void)
{
    sensor_filter(100.0f);
    sensor_filter(102.0f);
    sensor_filter(101.0f);
    sensor_filter(103.0f);
    sensor_filter(999.0f);  /* EMI spike */
    float val = sensor_read();
    TEST_ASSERT_FLOAT_WITHIN(1.0f, 101.5f, val);
}

/* @covers SR-003 */
void test_read_clean_after_spike(void)
{
    unsigned int i;
    for (i = 0u; i < 5u; i++)
    {
        sensor_filter(0.0f);
    }
    float val_before = sensor_read();
    sensor_filter(1000.0f);  /* spike — should be rejected */
    float val_after = sensor_read();
    TEST_ASSERT_EQUAL_FLOAT(val_before, val_after);
}

关键测试类型:

  1. 初始化状态测试:test_init_returns_zero验证模块在上电后的默认行为。在汽车软件中,初始化状态必须明确定义,不容许“未定义行为“。注意初始化通过setUp()在每个测试前自动完成,你不需要在每个测试中手动调用sensor_init()。

  2. 正常路径测试:test_filter_average_normal验证核心功能在正常输入下的正确性。5帧相同数值,取平均应等于输入。

  3. 异常路径测试:test_filter_rejects_single_spike验证滤波算法在尖峰干扰下的鲁棒性。四个正常值后跟一个999的尖峰,滤波后的均值不应受尖峰影响。这是安全关键测试。如果传感器滤波不能正确抑制电磁干扰尖峰,下游的控制器可能做出错误决策。

  4. 状态不变性测试:test_read_clean_after_spike验证被拒绝的尖峰不会影响下次读取的结果。注入尖峰前后的读取值应完全一致。

测试驱动开发的嵌入式适配

TDD的经典循环是:Red(写测试,失败)→ Green(写最小代码,通过)→ Refactor(优化代码,测试仍绿)。这在嵌入式领域需要一些适配:

  1. 硬件依赖的Mock:sensor_read()不依赖硬件,因为eng-lite的教学设计把它简化了。但在生产代码中,你会用CMock来Mock Adc_ReadGroup()这个AUTOSAR MCAL函数。

  2. 中断上下文的模拟:在测试中,你可以通过直接调用中断服务函数来模拟中断行为,不需要真实的定时器中断。

  3. 时间的控制:嵌入式测试中,时间依赖是最难处理的。解决方案是注入一个可控的“tick“函数,而不是依赖真实的系统时钟。

覆盖率报告

make coverage执行的是三道工序:

  1. ceedling gcov:all:用-fprofile-arcs -ftest-coverage编译标志重新编译并运行测试
  2. ceedling utils:gcov:调用gcovr收集覆盖率数据并生成HTML报告
  3. 将报告复制到build/coverage/index.html

打开HTML报告,你会看到:

sensor.c — Line Coverage: 100.0% (32/32 lines)
├── sensor_init()      100% (5/5)
├── sensor_filter()    100% (15/15)
└── sensor_read()      100% (12/12)

在汽车行业,覆盖率要求取决于安全完整性等级(ASIL):

  • ASIL A:没有强制覆盖率要求
  • ASIL B:分支覆盖率(Branch Coverage)
  • ASIL C:分支覆盖率 + 修改条件/判定覆盖率(MC/DC)
  • ASIL D:全部分支 + MC/DC + 目标覆盖率100%

eng-lite的目标是行覆盖率>90%,这是实用的教学级目标。

核心洞察:好的测试从四个维度逼近行为:初始化状态、正常路径、异常路径、状态不变性。 每个用例都锁定一个行为契约——上电默认值明确定义、均值滤波正确收敛、尖峰被拒绝后下游不误判、被拒尖峰不污染下一次读取。覆盖率门槛则随ASIL逐级抬升,从ASIL A的无强制要求到ASIL D的全分支+MC/DC+100%目标覆盖——测试写什么、门槛定多高,都要由安全完整性等级说了算。

覆盖率不是目标,是手段

一种常见的错误是:为了覆盖率而写测试。 团队设置了一个覆盖率指标(比如行覆盖率>90%),然后开发者为达到这个指标而写测试。这种测试往往是低质量的:它们只是“触碰“了代码行,但并没有真正验证代码的行为。

一个极端的反例:对于函数int clamp(int x) { return x > 100 ? 100 : x < 0 ? 0 : x; },你可以写一个测试TEST_ASSERT_TRUE(1)来让覆盖率工具显示代码被执行到了。但这不是测试,这是作弊,而且你首先就是在欺骗自己。

正确的覆盖率使用方式是:先把所有你能想到的正确性必须验证的测试写好,然后看覆盖率报告,分析未覆盖的行。 未覆盖的行可能属于以下几种情况:

  1. 死代码:应该删除。在汽车安全标准中,死代码是不被允许的(MISRA C Rule 2.2)。
  2. 防御性代码:比如default: /* should never reach */ break;。这条路径在正常测试中不会被执行,但它是安全机制的一部分。这种未覆盖是可以接受的,但需要偏离记录(deviation)。
  3. 硬件相关代码:需要在HIL测试中覆盖,单元测试无法覆盖。
  4. 遗漏的正常路径:你需要补写测试用例。这才是覆盖率工具真正的价值:告诉你你的测试盲区在哪。

嵌入式TDD的实战节奏

对于嵌入式开发者,TDD的经典“红-绿-重构“循环有一些适配:

第零步:搭建测试框架。 在你的项目里,运行一次ceedling new my_project来看看标准项目结构是什么样的。这只需要五分钟,但会让你对“我的代码怎样被测试框架吞进去“有一个直观的感受。

第一步:确定可测试的边界。 不是所有代码都适合单元测试。直接操控硬件寄存器的代码(比如MCAL驱动)很难在PC上进行单元测试,你需要HIL。但算法逻辑(滤波、校验、状态转换、报文组装/解析)是单元测试的最佳候选。

第二步:从最危险的地方开始。 选择“如果这个函数出错,后果最严重“的地方。在ISO 26262体系中,这就是最高ASIL等级的需求。

第三步:先写happy path测试,再写边界和异常测试。 不要一开始就想覆盖所有情况。先确认基本功能正确,再逐步加固。

第四步:在每次重构之前跑一次全部测试。 重构的风险在于改变内部结构但不改变外部行为,没有测试的重构就是赌博。

核心洞察:覆盖率是手段不是目标,未覆盖的行才是工具真正的价值所在。 为指标而写的测试只是“触碰“代码行——一个TEST_ASSERT_TRUE(1)就能骗过覆盖率工具,但你在骗自己。正确用法是先写全必须验证的正确性测试,再逐行分析未覆盖:死代码该删、防御性代码该留但附偏离记录、硬件代码移交HIL、遗漏路径则补测试。单元测试的价值不在当下而在三个月后,它是改动代码时承重测试给的确定性,不是给KPI的数字。

给核心算法补上测试

回到你自己的项目。找一段核心算法代码,不管是滤波、校验、状态机转换,还是报文封装。为它写五个测试用例。不需要搭建完整框架,可以用最简单的方式:

#include <assert.h>
#include <math.h>
int main(void) {
    assert(fabs(my_func(1.0) - expected) < 0.001);
    assert(my_func(0) == 0);
    /* ...3 more... */
    return 0;
}

感受一下:当你知道你的代码可以被一行命令验证时,你的编程心态会发生什么变化? 这个变化,就是我们追求的东西。


本篇小结

  • 单元测试体系:本章带你走过了eng-lite的单元测试体系。
  • Ceedling/Unity配置:你看到了Ceedling/Unity的配置。
  • 测试类型:四种关键的测试类型:初始化/正常/异常/状态转换。
  • TDD适配:TDD在嵌入式领域的适配。
  • 覆盖率报告:覆盖率报告的使用。
  • 覆盖率指标:那个接近100%的覆盖率指标不是KPI,它是一张安全网。它告诉你:你的测试触碰了几乎全部的代码路径。
  • 剩余覆盖缺口:剩下那一点点要审慎地分析:是死代码可以删除?是特殊硬件路径需要HIL测试覆盖?还是防御性默认赋值,不需要测试但需要偏离记录。

【下集预告】:第5.5章,我们将把这些独立的环节连接成一个自动化流水线。代码推送到仓库,CI系统在云端的容器里编译、检查、测试、报告,不需要你手动敲任何一个命令。你只需要在收到“Build Passed“通知时,拿起手机,微笑一下,继续写下一段代码。