18、宏与单元测试:测试框架中的宏应用(TEST, ASSERT_EQ),模拟与桩函数,覆盖率统计宏
单元测试,说白了就是给代码上“保险”。
我做了十几年嵌入式开发,见过太多“改一行代码,崩一片功能”的惨案。后来我养成了一个习惯——写代码之前,先把测试框架搭好。而宏,正是测试框架的灵魂。
18.1 测试框架中的宏:TEST 与 ASSERT_EQ
你想想看,一个测试框架最核心的需求是什么?
- 能定义测试用例
- 能判断结果对不对
- 能自动运行并报告结果
这些功能,用宏来实现再合适不过了。因为宏可以在编译期生成代码,把测试用例注册到框架中。
我常用的一个轻量级测试框架是这样设计的:
#define TEST(name) \
void test_##name(); \
__attribute__((constructor)) void reg_##name() { \
register_test(#name, test_##name); \
} \
void test_##name()
#define ASSERT_EQ(expected, actual) \
do { \
if ((expected) != (actual)) { \
test_fail(__FILE__, __LINE__, #expected, #actual, expected, actual); \
return; \
} \
} while(0)
// 使用示例
TEST(addition) {
ASSERT_EQ(4, 2 + 2);
ASSERT_EQ(10, 5 + 5);
}
TEST(subtraction) {
ASSERT_EQ(3, 5 - 2);
}
这里有个关键点——__attribute__((constructor))。这个 GCC 扩展能让函数在 main 之前自动执行。我在项目中用过这个技巧,把测试注册做到了“零配置”。
核心思路:TEST 宏不仅定义了测试函数,还自动生成了一个注册函数。这个注册函数在程序启动时就把测试用例挂到了框架的链表里。你只管写测试,剩下的交给宏。
ASSERT_EQ 里的 do-while 结构,你可能觉得多余。但这是 C 语言的经典技巧——保证宏展开后是一个完整的语句,不会跟周围的代码打架。我曾经见过有人没加这个,结果在 if 语句里用 ASSERT_EQ 直接编译报错。
18.2 模拟与桩函数:用宏“造假”
做嵌入式测试,最头疼的就是硬件依赖。你不能每次测试都真的去读写寄存器吧?
这时候就需要桩函数(stub)和模拟(mock)。说白了,就是用宏把真实的函数调用替换成假的。
我常用的桩函数宏:
#define STUB_FUNC(return_type, func_name, ...) \
return_type func_name(__VA_ARGS__) { \
static int call_count = 0; \
call_count++; \
/* 默认返回 0 */ \
return (return_type)0; \
}
// 生成一个桩函数
STUB_FUNC(int, read_adc, int channel)
// 更高级的模拟宏
#define MOCK_FUNC(return_type, func_name, ...) \
static return_type mock_##func_name##_result; \
static int mock_##func_name##_call_count = 0; \
return_type func_name(__VA_ARGS__) { \
mock_##func_name##_call_count++; \
return mock_##func_name##_result; \
}
MOCK_FUNC(int, get_temperature, void)
// 测试中设置返回值
mock_get_temperature_result = 25;
ASSERT_EQ(25, get_temperature());
嗯,这里要注意——宏展开后的静态变量是全局的。如果你跑多个测试用例,记得在 setUp 里重置这些变量。我踩过这个坑,测试顺序不同结果就不同,查了半天才发现是 mock 的调用计数没清零。
我的习惯:每个测试文件开头,用宏批量生成桩函数。然后在测试用例里只关心逻辑,不关心硬件。这样代码的可移植性也提高了——换个平台,换个桩函数实现就行。
18.3 覆盖率统计宏:你到底测了多少?
光写测试还不够,你得知道测了多少代码。覆盖率统计就是干这个的。
我见过一些团队,测试写了一堆,但覆盖率只有 30%。为什么?因为很多分支根本没跑到。覆盖率宏可以帮你发现这些盲区。
一个简单的覆盖率统计宏:
#define COVERAGE_LINE(line_id) \
do { \
static int covered_##line_id = 0; \
if (!covered_##line_id) { \
covered_##line_id = 1; \
coverage_report(#line_id, __FILE__, __LINE__); \
} \
} while(0)
// 在代码中埋点
void process_data(int x) {
COVERAGE_LINE(100);
if (x > 0) {
COVERAGE_LINE(101);
// 处理正数
} else {
COVERAGE_LINE(102);
// 处理负数
}
}
当然,实际项目中不会手动埋点。GCC 的 -fprofile-arcs -ftest-coverage 选项可以自动生成覆盖率信息。但理解宏的原理,能帮你更好地解读覆盖率报告。
避坑指南:我曾经在一个项目里手动加了 200 多个 COVERAGE_LINE 宏,结果代码体积膨胀了 30%,运行速度也慢了。后来改用 gcov 工具,才发现自己做了无用功。覆盖率统计宏适合做“轻量级”的快速验证,正式发布时一定要关掉。
18.4 知识体系总览
下面这张图,把宏在单元测试中的三个核心应用串起来了:
从这张图你能看到,宏在测试中扮演了三个角色:
- 测试框架宏:帮你自动注册测试用例,断言结果
- 模拟与桩函数:让你在测试环境里“造假”,摆脱硬件依赖
- 覆盖率统计宏:帮你量化测试的完整性
这三个角色,说白了就是让测试变得“自动化、可模拟、可度量”。
总结一下:宏在单元测试中的价值,不是因为它能写出多花哨的代码,而是因为它能帮你建立一套“测试文化”。我见过太多团队,测试代码写得比业务代码还复杂,最后没人愿意维护。而用宏封装好的测试框架,能让写测试变成一件轻松的事。
记住一句话:好的测试框架,应该是“写测试比写代码更简单”。宏,就是实现这个目标的利器。
公众号:蓝海资料掘金营,微信deep3321