单元测试:测试框架选择、测试用例设计、边界值测试、Mock对象使用

单元测试这件事,我做了十几年,踩过的坑比写过的代码还多。说实话,很多嵌入式工程师对单元测试的态度是「知道很重要,但就是懒得写」。我完全理解——嵌入式环境复杂,硬件依赖多,跑个测试还得搭平台。但我要告诉你,不写单元测试的代价,往往比写测试的成本高出一个数量级。

今天我们就来聊聊,怎么在C语言嵌入式项目里,把单元测试真正落地。不是那种「为了测试而测试」的形式主义,而是能帮你提前发现bug、重构时心里有底的那种。

测试框架怎么选?

选测试框架,说白了就是选一个「能让你舒服地写测试」的工具。我个人习惯用CUnitUnity,这两个在嵌入式圈子里用得最多。

框架 特点 适用场景
CUnit 功能全面,支持断言、测试套件、报告生成 桌面端开发、Linux环境
Unity 轻量级,纯C实现,无依赖 嵌入式裸机、资源受限环境
CMock 配合Unity使用,自动生成Mock 需要大量Mock的场景

我建议初学者从Unity入手。为什么?因为它简单到令人发指——就一个.c和一个.h文件,往项目里一丢就能用。我在一个STM32项目里用过,编译出来才几KB,完全不占资源。

核心原则:测试框架不是越强大越好,而是越「适合你的项目」越好。资源受限的MCU项目,别用CUnit那种重量级框架。

测试用例设计:别只测「正常路径」

很多新手写测试,只测「输入合法参数,得到正确结果」这条路径。这远远不够。我见过一个项目,所有单元测试都通过了,结果集成时一跑就崩——因为没人测过「参数为NULL」的情况。

测试用例设计,我一般遵循等价类划分边界值分析这两个方法。

等价类划分

把输入数据分成若干「等价类」,每个类里选一个代表值来测。比如一个函数接受0-100的整数,那我们可以分成:

  • 有效等价类:0-100之间的值
  • 无效等价类:小于0的值
  • 无效等价类:大于100的值

每个等价类选一个值测就行了,不用每个值都测。这能大幅减少测试用例数量。

边界值测试

边界值测试,说白了就是「在边界上反复横跳」。我遇到过最经典的bug,是一个温度传感器驱动,在0°C时返回了错误值。为什么?因为代码里写的是 if (temp > 0),而不是 if (temp >= 0)。边界值没测到,上线就出问题。

注意:边界值测试要覆盖「上边界、下边界、边界附近的值」。比如范围是0-100,那你要测-1、0、1、99、100、101这六个值。

Mock对象:把「依赖」隔离掉

嵌入式代码最头疼的就是硬件依赖。你写了个I2C驱动函数,要测它,总不能每次都接个真实传感器吧?这时候就需要Mock对象

Mock说白了就是「假函数」——它模拟真实硬件的行为,但由你控制返回值。比如:

// 真实函数
int read_sensor(int addr) {
    // 通过I2C读取硬件
    return i2c_read(addr);
}

// Mock函数
int __wrap_read_sensor(int addr) {
    // 返回预设值,不碰硬件
    if (addr == 0x50) return 25;  // 模拟温度25°C
    return -1;
}

在Unity里配合CMock使用,可以自动生成这些Mock函数。你只需要告诉它「我要Mock哪个函数」,它就能生成一个可控制的替身。

小技巧:用链接器的wrap功能,可以在不修改原代码的情况下替换函数。编译时加 -Wl,--wrap=read_sensor,所有对read_sensor的调用都会自动转到__wrap_read_sensor。

一个完整的测试例子

我们来看一个实际案例。假设有个函数,计算电池剩余电量百分比:

// battery.c
int battery_level(int voltage_mv) {
    if (voltage_mv < 3000) return 0;   // 欠压
    if (voltage_mv > 4200) return 100; // 过压
    return (voltage_mv - 3000) * 100 / 1200;
}

用Unity写测试:

#include "unity.h"
#include "battery.h"

void setUp(void) {}
void tearDown(void) {}

void test_battery_level_low(void) {
    TEST_ASSERT_EQUAL(0, battery_level(2999));
    TEST_ASSERT_EQUAL(0, battery_level(3000));  // 边界
}

void test_battery_level_high(void) {
    TEST_ASSERT_EQUAL(100, battery_level(4200)); // 边界
    TEST_ASSERT_EQUAL(100, battery_level(5000));
}

void test_battery_level_normal(void) {
    TEST_ASSERT_EQUAL(50, battery_level(3600));
    TEST_ASSERT_EQUAL(25, battery_level(3300));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_battery_level_low);
    RUN_TEST(test_battery_level_high);
    RUN_TEST(test_battery_level_normal);
    return UNITY_END();
}

你看,边界值测试覆盖了3000和4200这两个关键点。如果代码里写的是 if (voltage_mv <= 3000),那测试就能立刻发现逻辑问题。

单元测试的知识体系

下面这张图,是我自己总结的单元测试核心逻辑。你一看就明白:

单元测试 测试框架选择 测试用例设计 边界值测试 Mock对象使用 CUnit / Unity 轻量 / 无依赖 等价类划分 边界值分析 隔离硬件依赖 CMock自动生成 目标:提前发现bug,保障重构安全

避坑指南

我曾经在一个项目里,测试覆盖率做到了90%以上,结果上线还是出了问题。后来一查,发现所有测试都用了同一个Mock返回值——根本没测到异常分支。这就是典型的「为了覆盖率而测试」。

几个我踩过的坑,你注意避开:

  • Mock太「完美」:Mock函数永远返回成功,导致真实场景下的错误处理代码从来没被执行过。记得Mock里也要模拟失败情况。
  • 忽略全局状态:嵌入式代码里很多全局变量,测试之间会互相影响。每个测试用例执行前,记得用setUp()重置状态。
  • 测试和代码耦合太紧:测试用例里直接依赖了内部实现细节,导致重构时测试也跟着改。测试应该测「行为」,而不是「实现」。

我的习惯:每次提交代码前,先跑一遍所有单元测试。如果测试没通过,绝不提交。这个习惯帮我挡住了至少几十次线上事故。

单元测试这件事,说白了就是「花时间省时间」。前期多花点功夫写测试,后期调试和重构时就能省下大把时间。别等到出了问题再后悔——我见过太多项目,因为没写单元测试,最后代码改都不敢改,整个项目烂在手里。

嗯,今天就聊到这儿。记住:好的单元测试,是你代码的「安全带」。别嫌麻烦,系上它。