17、宏实现单元测试框架:测试用例的宏定义,测试套件的组织,断言宏的实现

单元测试,说白了就是给代码做「体检」。我做了十几年嵌入式开发,见过太多「看起来没问题,一跑就崩」的代码。后来我养成了一个习惯——写代码之前,先把测试架子搭好。今天我们就聊聊,怎么用宏搭一个轻量级的单元测试框架。

17.1 为什么用宏做测试框架?

你可能会问:「现在测试框架那么多,干嘛自己用宏写?」

嗯,原因其实很简单。嵌入式环境里,资源有限,很多第三方测试库根本跑不起来。而且,宏在预处理阶段就展开了,不会增加运行时开销。我在一个ARM Cortex-M0的项目里,连printf都舍不得用,更别说装一个完整的测试框架了。这时候,自己用宏搭一个,反而最实在。

17.2 测试用例的宏定义

测试用例,就是一个个独立的测试函数。我们需要一个宏,能自动注册、自动运行这些用例。

我个人习惯这样设计:

#define TEST_CASE(name) \
    void test_##name(void); \
    void test_##name(void)

用法很简单:

TEST_CASE(addition) {
    // 测试代码
}

这个宏展开后,就变成了一个叫 test_addition 的函数。为什么加 test_ 前缀?避免命名冲突。我在一个大型项目里,就遇到过测试函数和业务函数重名的问题,排查了半天……从那以后,我所有测试函数都强制加前缀。

小技巧: 如果你想让测试用例自动注册,可以结合 __attribute__((constructor)) 或者自定义段。不过嵌入式编译器不一定支持,我一般手动维护一个测试列表。

17.3 测试套件的组织

测试用例多了,就得分组管理。测试套件就是一组相关的测试用例。比如「数学运算测试套件」包含加法、减法、乘法测试。

我常用的组织方式是这样的:

#define TEST_SUITE_BEGIN(name) \
    void suite_##name(void) { \
        printf("=== Running Suite: %s ===\n", #name);

#define TEST_SUITE_END() \
        printf("=== Suite Complete ===\n"); \
    }

然后在套件里调用测试用例:

TEST_SUITE_BEGIN(math)
    RUN_TEST(addition);
    RUN_TEST(subtraction);
    RUN_TEST(multiplication);
TEST_SUITE_END()

这里的 RUN_TEST 又是一个宏:

#define RUN_TEST(name) \
    do { \
        printf("Running test: %s ... ", #name); \
        test_##name(); \
        printf("PASSED\n"); \
    } while(0)

为什么用 do { ... } while(0)?这是C语言里一个经典技巧。保证宏在 ifelse 等复杂语句中也能正确展开。我曾经见过同事因为没加这个,导致测试跳过了一半用例……

17.4 断言宏的实现

断言是测试框架的灵魂。没有断言,测试用例跑完你也不知道对不对。

最基本的断言宏:

#define ASSERT_TRUE(cond) \
    do { \
        if (!(cond)) { \
            printf("FAIL: %s at %s:%d\n", #cond, __FILE__, __LINE__); \
            return; \
        } \
    } while(0)

#define ASSERT_EQ(expected, actual) \
    do { \
        if ((expected) != (actual)) { \
            printf("FAIL: expected %d, got %d at %s:%d\n", \
                   (expected), (actual), __FILE__, __LINE__); \
            return; \
        } \
    } while(0)

这里用了 __FILE____LINE__,这是编译器预定义的宏。一旦断言失败,能精确告诉你哪个文件的哪一行出了问题。我在调试一个多线程程序时,这个信息救了我好几次。

核心要点: 断言失败后,用 return 而不是 exit。这样当前测试用例失败,不会影响后续用例的执行。你可以收集到所有失败信息,而不是「一错就停」。

17.5 完整的测试框架示例

把上面这些拼起来,就是一个完整的微型测试框架:

#include <stdio.h>

// 测试用例宏
#define TEST_CASE(name) \
    void test_##name(void); \
    void test_##name(void)

// 断言宏
#define ASSERT_TRUE(cond) \
    do { \
        if (!(cond)) { \
            printf("FAIL: %s at %s:%d\n", #cond, __FILE__, __LINE__); \
            return; \
        } \
    } while(0)

#define ASSERT_EQ(expected, actual) \
    do { \
        if ((expected) != (actual)) { \
            printf("FAIL: expected %d, got %d at %s:%d\n", \
                   (expected), (actual), __FILE__, __LINE__); \
            return; \
        } \
    } while(0)

// 运行测试宏
#define RUN_TEST(name) \
    do { \
        printf("Running test: %s ... ", #name); \
        test_##name(); \
        printf("PASSED\n"); \
    } while(0)

// 测试套件宏
#define TEST_SUITE_BEGIN(name) \
    void suite_##name(void) { \
        printf("=== Running Suite: %s ===\n", #name);

#define TEST_SUITE_END() \
        printf("=== Suite Complete ===\n"); \
    }

// 示例:被测试函数
int add(int a, int b) {
    return a + b;
}

// 示例:测试用例
TEST_CASE(addition) {
    ASSERT_EQ(3, add(1, 2));
    ASSERT_EQ(0, add(-1, 1));
    ASSERT_TRUE(add(100, 200) > 0);
}

TEST_CASE(subtraction) {
    ASSERT_EQ(1, add(3, -2));
}

// 示例:测试套件
TEST_SUITE_BEGIN(math)
    RUN_TEST(addition);
    RUN_TEST(subtraction);
TEST_SUITE_END()

int main() {
    suite_math();
    return 0;
}

17.6 测试框架的核心流程

为了让你更直观地理解,我画了一张图:

宏实现单元测试框架核心流程 main() 入口 TEST_SUITE_BEGIN(math) RUN_TEST(addition) ASSERT_EQ / ASSERT_TRUE 展开为函数调用 执行测试用例 断言检查

17.7 进阶技巧与避坑指南

光有基础功能还不够,实际项目中会遇到各种坑。我分享几个经验:

  • 断言信息要详细:除了 __FILE____LINE__,最好把实际值和期望值都打印出来。我见过只打印「FAIL」的断言,查问题跟大海捞针一样。
  • 测试隔离:每个测试用例之间不要有状态依赖。我曾经在一个项目里,测试A修改了全局变量,测试B依赖这个变量,结果测试顺序一变就全挂了。
  • 不要用 assert.h:标准库的 assert 会调用 abort(),直接终止程序。单元测试需要的是「温和失败」,而不是「同归于尽」。
  • 考虑浮点数比较:如果你测试浮点运算,直接用 == 会出问题。可以加一个 ASSERT_NEAR 宏,允许一定误差。
注意: 宏展开后的代码会变得很长,调试时不好定位。建议在开发阶段开启编译器的 -E 选项,查看预处理后的代码,确认宏展开是否符合预期。

17.8 扩展:测试统计与报告

如果你想让框架更完善,可以加入统计功能。比如记录测试总数、通过数、失败数。我一般这样实现:

static int test_passed = 0;
static int test_failed = 0;

#define RUN_TEST(name) \
    do { \
        printf("Running test: %s ... ", #name); \
        test_##name(); \
        test_passed++; \
        printf("PASSED\n"); \
    } while(0)

// 在断言失败时,增加失败计数
#define ASSERT_TRUE(cond) \
    do { \
        if (!(cond)) { \
            printf("FAIL: %s at %s:%d\n", #cond, __FILE__, __LINE__); \
            test_failed++; \
            return; \
        } \
    } while(0)

然后在套件结束时打印统计信息:

#define TEST_SUITE_END() \
        printf("=== Suite Complete ===\n"); \
        printf("Passed: %d, Failed: %d\n", test_passed, test_failed); \
    }

这样你就能一眼看出测试结果了。我在一个持续集成项目里,就是靠这个统计信息自动判断构建是否通过。

17.9 总结

用宏实现单元测试框架,核心就三件事:定义测试用例、组织测试套件、实现断言宏。宏虽然看起来「简陋」,但在嵌入式环境里,它是最轻量、最可控的方案。你不需要依赖任何第三方库,一个头文件就能搞定。

当然,如果你的项目跑在Linux或者Windows上,直接用成熟的测试框架(比如Unity、CUnit)会更省事。但理解宏实现的原理,能让你在遇到问题时,知道底层到底发生了什么。

好了,今天的分享就到这里。希望这些经验能帮你写出更健壮的代码。


公众号:蓝海资料掘金营,微信deep3321