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语言里一个经典技巧。保证宏在 if、else 等复杂语句中也能正确展开。我曾经见过同事因为没加这个,导致测试跳过了一半用例……
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 测试框架的核心流程
为了让你更直观地理解,我画了一张图:
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)会更省事。但理解宏实现的原理,能让你在遇到问题时,知道底层到底发生了什么。
好了,今天的分享就到这里。希望这些经验能帮你写出更健壮的代码。