Lesson 60: 微型 C 测试框架
练习任务
难度:中
用纯 C 预处理器宏实现一个微型单元测试框架。你需要完成四个核心宏和五个自测用例:
ASSERT_EQ(a, b)— 整数相等断言,失败时打印文件、行号、期望值和实际值ASSERT_STREQ(a, b)— 字符串符串相等断言,正确处理 NULL 指针(双 NULL 视为相等)TEST(name) { ... }— 用##记号粘贴生成test_##name函数RUN_TESTS()— 运行所有注册的测试,打印 PASS/FAIL 状态和汇总
你需要写五个测试用例验证框架正确性,其中 test_fail 和 test_float 是故意写错的断言,用于验证失败报告功能。
./test_framework
Running 5 test(s)...
[test_add] PASS
[test_str] PASS
[test_fail] FAIL test_framework.c:87: ASSERT_EQ(2 + 2, 5) — expected 5, got 4
[test_null] FAIL test_framework.c:96: ASSERT_STREQ(NULL, "hello") — expected "hello", got "(null)"
[test_float] FAIL test_framework.c:107: ASSERT_EQ((int)2.718, 3) — expected 3, got 2
─── Summary ───
5 tests, 2 passed, 3 failed提示:这个框架的精华在于三个 C 预处理器技巧的结合——
#将表达式转为诊断字符串、##动态生成函数名、do { ... } while(0)让多语句宏安全嵌入任何上下文。思考:为什么 ASSERT_EQ 在失败后不调用exit()而是return?如果 ASSERT_STREQ 的参数里有函数调用(如get_name()),直接写在宏里会有什么问题?
核心知识点
#字符串化操作符 — 将宏参数转为 C 字符串字面量,是断言打印表达式文本的核心机制##记号粘贴操作符 — 合并两个预处理记号,实现 TEST 宏自动生成函数名的关键do { ... } while(0)惯用法 — 多语句宏的安全包装,编译器零成本消除- ASSERT_EQ 四要素输出 — 文件
__FILE__+ 行号__LINE__+ 期望值 + 实际值 - ASSERT_STREQ 的 NULL 安全逻辑 — XOR 判断单 NULL + 非 NULL 时 strcmp 比较,避免段错误
- 局部变量防重复求值 —
const char *_aa = (a)确保参数只求值一次 - TEST 宏与函数指针注册表 — 静态 TestEntry 数组存储测试函数指针和名称
- fail-fast 语义 — 断言失败后
return退出当前 TEST,剩余断言不再执行 returnvsexit的语义选择 — 退出测试而非终止进程,确保后续测试继续运行- 浮点断言的局限 — 整数断言无法精确比较浮点数,成熟框架需引入容差 epsilon
代码框架
#include <stdio.h>
#include <string.h>
/* ========== 断言宏 ========== */
#define ASSERT_EQ(a, b) do { \
/* ① 用 (a) != (b) 比较两个参数 */ \
if ((a) != (b)) { \
/* ② 用 __FILE__ 和 __LINE__ 定位失败位置 */ \
printf(" FAIL %s:%d: ASSERT_EQ(%s, %s) " \
"— expected %d, got %d\n", \
__FILE__, __LINE__, #a, #b, (int)(b), (int)(a)); \
/* ③ 失败计数 +1,return 退出当前 TEST */ \
fail_count++; \
return; \
} \
} while(0)
#define ASSERT_STREQ(a, b) do { \
/* ④ 用局部变量存储参数,防止重复求值 */ \
const char *_aa = (a); \
const char *_bb = (b); \
/* ⑤ XOR 判断单 NULL; 都非 NULL 则 strcmp */ \
if ((_aa == NULL) != (_bb == NULL) || \
(_aa != NULL && _bb != NULL && strcmp(_aa, _bb) != 0)) { \
printf(" FAIL %s:%d: ASSERT_STREQ(%s, %s) " \
"— expected \"%s\", got \"%s\"\n", \
__FILE__, __LINE__, #a, #b, \
_bb ? _bb : "(null)", _aa ? _aa : "(null)"); \
fail_count++; return; \
} \
} while(0)
/* ========== 测试注册结构 ========== */
typedef struct {
void (*func)(void); /* 函数指针 */
const char *name; /* 测试名名称 */
} TestEntry;
/* ⑥ 用 static 确保内部链接,避免多文件符号冲突 */
static TestEntry tests[64];
static int test_index = 0;
static int test_count = 0;
static int fail_count = 0;
#define register_test(t) do { \
tests[test_index].func = (t); \
tests[test_index].name = #t; \
test_index++; \
} while(0)
/* ⑦ 用 ## 记号粘贴生成函数名: TEST(add) → test_add */
#define TEST(name) static void test_##name(void)
/* ⑧ 遍历 tests 数组,执证了每个测试函数,判断 PASS/FAIL */
#define RUN_TESTS() do { \
/* 在这里实现测试执行和汇总逻辑 */ \
} while(0)骨架中 // ① 到 // ⑧ 标记了需要填充的关键部分。建议在纸上先画出框架的完整运行流程——从 TEST 宏展开到函数指针注册、再到 RUN_TESTS 的循环调用——然后再动手写代码。
TIP
先不要往下翻看参考解答。重点关注三个问题:(1) #a 输出的是变量值还是表达式文本?(2) ## 如何把 test 和 add 变成 test_add?(3) 为什么 ASSERT_STREQ(NULL, NULL) 应该 PASS?
深度讲解
1. C 预处理器核心操作符——测试框架的基础设施
1.1 # — 字符串化操作符 (Stringification)
# 操作符在宏展开阶段将参数转数转换为 C 字符串字面量。它拿到的是参数的源码文本,不是运行时值。
#define STR(x) #x
STR(hello) // → "hello"
STR(1 + 2) // → "1 + 2" ← 注意: 是表达式文本, 不是 "3"!
STR(assert_eq) // → "assert_eq"在 ASSERT_EQ 中,这一特性决定了打印内容:
// 源码: ASSERT_EQ(2 + 2, 5)
// 展开后 #a → "2 + 2"(源码文本),(int)(a) → 4(运行时求值)
// 输出: "... ASSERT_EQ(2 + 2, 5) — expected 5, got 4"NOTE
# 工作在编译前的预处理阶段。你无法用它打印变量在运行时的当前值——它永远打印源代码中写的那段文本。这就是为什么 ASSERT_EQ 同时需要 (#a, #b) 和 ((int)(a), (int)(b)) ——前者给出表达式文本,后者给出运行时的实际值。
1.2 ## — 记号粘贴(Token Pasting)
## 将两个预处理记号合并为一个新记号。TEST 宏用它实现 test_add、test_str 等动态函数名:
#define MAKE_FUNC(name) static void test_##name(void)
MAKE_FUNC(add) // → static void test_add(void)
MAKE_FUNC(str_cmp) // → static void test_str_cmp(void)
// 完整示例:
TEST(add) { ASSERT_EQ(1 + 1, 2); }
// 展开后: static void test_add(void) { do { if ((1+1) != (2)) ... } while(0); }1.3 do { ... } while(0) — 多语句宏的安全包装
宏体中有多条语句时,如果不包裹,在与 if/else 组合时会引发语法错误:
错误——不用 do-while(0):
#define FOO() stmt1(); stmt2()
if (cond)
FOO(); // 展开: if (cond) stmt1(); stmt2();
else
bar(); // 语法错错误!else 找不到不到匹配的 if
正确——用 do-while(0):
#define FOO() do { stmt1(); stmt2(); } while(0)
if (cond)
do { stmt1(); stmt2(); } while(0); // 一条完整语句
else
bar(); // 正确编译器能识别这个惯用法并完全消除 do-while 循环——环,生成的机器码与直接写语句完全相同。它是"零成本抽象"在预处理器层面的体现。
2. ASSERT_EQ 宏的完整设计
2.1 四要素输出模型
ASSERT_EQ 的失败输出包含四个关键信息:
| 输出要素 | 信息 | 实现机制 | 示例 |
|---|---|---|---|
| 定位 | 文件 + 行号 | __FILE__ + __LINE__ | test_framework.c:87 |
| 语义 | 表达式文本 | #a, #b 字符串化 | ASSERT_EQ(2 + 2, 5) |
| 期望值 | b 的运行时值 | (int)(b) | 5 |
| 实际值 | a 的运行时值 | (int)(a) | 4 |
2.2 宏展开完整追踪
以 ASSERT_EQ(2 + 2, 5) 为例:
Step 1 — 源代码: ASSERT_EQ(2 + 2, 5);
Step 2 — 宏展开:
do {
if ((2 + 2) != (5)) {
printf("... ASSERT_EQ(%s, %s) ...", "2 + 2", "5", (int)(5), (int)(2+2));
fail_count++; return;
}
} while(0);
Step 3 — 执行: (2+2)=4, (5)=5, 4 != 5 → 进入 if
Step 4 — 输出: FAIL test_framework.c:87: ASSERT_EQ(2 + 2, 5) — expected 5, got 42.3 括号保护——防御性编程的铁律
/* 不加括号的灾难: */
#define BAD_ASSERT(a, b) if (a != b) printf(...)
BAD_ASSERT(x & 1, 0)
// 展开: if (x & 1 != 0) ← != 优先级高于 &!
// 等效于 if (x & (1 != 0)),本意是 if ((x & 1) != 0)
/* 正确: 每个参数都用 () 包裹 */
#define ASSERT_EQ(a, b) do { if ((a) != (b)) ... } while(0)WARNING
宏参数每次出现都会被求值。ASSERT_EQ(x++, 5) 中 x++ 出现多次(#a、(a)、(int)(a)),导致不可预测的副作用。断言中永远避免使用带副作用的表达式。
3. ASSERT_STREQ 的 NULL 安全设计
3.1 问题与真值表
strcmp(NULL, ...) 会导致段错误。必须在调用 strcmp 之前检查 NULL:
NULL 比较真值表:
NULL vs NULL → PASS (双 NULL 视为相等)
NULL vs "hello" → FAIL (仅一个为 NULL)
"hello" vs NULL → FAIL (仅一个为 NULL)
"hello" vs "hello" → PASS (strcmp == 0)
"hello" vs "world" → FAIL (strcmp != 0)/* 错误——双 NULL 也会失败: */
if (_aa == NULL || _bb == NULL || strcmp(_aa, _bb) != 0)
// _aa=NULL, _bb=NULL → _aa==NULL 为真 → 短路 → 进入失败分支 ← BUG!
/* 正确——XOR 判断 NULL 状态是否一致: */
if ((_aa == NULL) != (_bb == NULL) || // 单 NULL?
(_aa != NULL && _bb != NULL && strcmp(_aa, _bb) != 0)) // 都非 NULL 且不等?XOR 逻辑:两者都为 NULL (1 != 1 = 0 通过);都(含 NULL (0 != 0 = 0 进入 strcmp);仅一个为 NULL (1 != 0 = 1 失败)。
3.2 局部变量防重复求值
/* 为什么不能直接写 strcmp((a), (b))? */
ASSERT_STREQ(get_name(), expect);
// get_name() 被调用了两次——一次在 strcmp 中,一次在 printf("%s"...) 中
// 如果 get_name() 每次返回不同值(如读取文件的下一行),失败报告会显示错误的值
/* 正确: 用局部变量存储 */
// const char *_aa = (a); ← 只求值一次
// const char *_bb = (b); ← 只求值一次4. TEST 宏与函数指针注册表
架构概览:
TEST(add) { ... } TEST(str) { ... } TEST(fail) { ... } ...
│ │ │
▼ ▼
┌───────────────────────────────────────────────┐
│ TestEntry tests[64] │
│ [0] func→test_add, name="test_add" │
│ [1] func→test_str, name="test_str" │
│ [2] func→test_fail,name="test_fail" │
└──────────────────────┬────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ RUN_TESTS() │
│ for i=0..test_index-1: │
│ before = fail_count │
│ tests[i].func() ← 通过函数指针调用 │
│ if fail_count == before → PASS else → 已打印 │
│ print summary │
└───────────────────────────────────────────────┘函数指针语法:void (*fp)(void) 是指向函数的指针(注意 void *fp(void) 是返回指针的函数);fp = my_func 函数名自动退化为指针;fp() 通过指针调用,开销仅 1-2 个 CPU 周期。
在标准 C 中无法做到全自动注册(C 没有 C++ 的全局构造函数机制)。本框架用显式 register_test(test_add) 手动注册——虽然略显繁琐但清晰透明。
5. 断言失败控制流——return vs exit
5.1 fail-fast 语(0)
ASSERT_EQ 失败时执行 fail_count++ 然后 return。return 从当前 TEST 函数返回,不终止进程:
TEST(something) {
ASSERT_EQ(a, b); ← 失败: 打印 FAIL → fail_count++ → return
ASSERT_EQ(c, d); ← 不会执行!(上一个断言已 return)
}5.2 为什么不用 exit(1)?
| 方式 | 行为 | 后果 |
|---|---|---|
return | 退出当前 TEST | 控制权回到 RUN_TESTS,继续下一个测试 |
exit(1) | 终止整个进程 | 剩余测试全部丢失,看不到完整失败列表 |
用 return 确保所有测试都有机会运行——在调试时你希望看到全部失败,而非仅第一个。
6. 浮点断言——整数框架的边界
ASSERT_EQ 通过 (int) 强制转换比较浮点数:
ASSERT_EQ((int)3.14, 3) // (int)3 == 3 → PASS
ASSERT_EQ((int)2.718, 3) // (int)2 == 3 → FAIL
// 直直接比较浮点数几乎肯定失败:
ASSERT_EQ(0.1 + 0.2, 0.3) // IEEE 754: 0.1+0.2 = 0.30000000000000004成熟框架用容差(epsilon)比较:
#define ASSERT_DOUBLE_EQ(a, b, eps) do { \
double _da = (a), _db = (b); \
if (fabs(_da - _db) > (eps)) { \
printf(" FAIL ... expected %g, got %g (eps=%g)\n", _db, _da, (double)(eps)); \
fail_count++; return; \
} \
} while(0)NOTE
浮点比较的容差不是"把比较放宽",而是承认浮点数代表实数的一个区间而非精确值。
参考解答
练习1: ASSERT_EQ 宏实现
#include <stdio.h>
static int fail_count = 0;
#define ASSERT_EQ(a, b) do { \
if ((a) != (b)) { \
printf(" FAIL %s:%d: ASSERT_EQ(%s, %s) " \
"— expected %d, got %d\n", \
__FILE__, __LINE__, #a, #b, (int)(b), (int)(a)); \
fail_count++; \
return; \
} \
} while(0)
static void test_eq(void) {
ASSERT_EQ(1 + 1, 2);
ASSERT_EQ(3 * 4, 12);
ASSERT_EQ(2 + 2, 5); /* 故意失败 */
}
int main(void) {
test_eq();
printf("fail_count = %d (expected 1)\n", fail_count);
return 0;
}要点:#a 打印表达式文本("2 + 2")而非求值结果(4);(int)(a) 打印求值结果。
练习2: ASSERT_STREQ 宏实现(含 NULL 安全)
#include <stdio.h>
#include <string.h>
static int fail_count = 0;
#define ASSERT_STREQ(a, b) do { \
const char *_aa = (a); \
const char *_bb = (b); \
if ((_aa == NULL) != (_bb == NULL) || \
(_aa != NULL && _bb != NULL && strcmp(_aa, _bb) != 0)) { \
printf(" FAIL %s:%d: ASSERT_STREQ(%s, %s) " \
"— expected \"%s\", got \"%s\"\n", \
__FILE__, __LINE__, #a, #b, \
_bb ? _bb : "(null)", _aa ? _aa : "(null)"); \
fail_count++; return; \
} \
} while(0)
static void test_streq(void) {
ASSERT_STREQ("hello", "hello");
ASSERT_STREQ(NULL, NULL); /* 双 NULL 通过 */
ASSERT_STREQ(NULL, "hello"); /* 失败 */
}
int main(void) {
test_streq();
printf("fail_count = %d (expected 1)\n", fail_count);
return 0;
}核心:(_aa == NULL) != (_bb == NULL) XOR 判断仅一一个为 NULL;局部变量防重复求值。
练习3: TEST 宏、函数指针注册表与 RUN_TESTS
#include <stdio.h>
#include <string.h>
/* 全局状态 */
static int test_count = 0;
static int fail_count = 0;
typedef struct {
void (*func)(void);
const char *name;
} TestEntry;
static TestEntry tests[64];
static int test_index = 0;
/* 断言宏 */
#define ASSERT_EQ(a, b) do { \
if ((a) != (b)) { \
printf(" FAIL %s:%d: ASSERT_EQ(%s, %s) — expected %d, got %d\n", \
__FILE__, __LINE__, #a, #b, (int)(b), (int)(a)); \
fail_count++; return; \
} \
} while(0)
#define ASSERT_STREQ(a, b) do { \
const char *_aa = (a); const char *_bb = (b); \
if ((_aa == NULL) != (_bb == NULL) || \
(_aa && _bb && strcmp(_aa, _bb) != 0)) { \
printf(" FAIL %s:%d: ASSERT_STREQ(%s, %s) — expected \"%s\", got \"%s\"\n", \
__FILE__, __LINE__, #a, #b, \
_bb ? _bb : "(null)", _aa ? _aa : "(null)"); \
fail_count++; return; \
} \
} while(0)
#define TEST(name) static void test_##name(void)
#define register_test(t) do { \
tests[test_index].func = (t); \
tests[test_index].name = #t; \
test_index++; \
} while(0)
#define RUN_TESTS() do { \
test_count = test_index; \
printf("Running %d test(s)...\n\n", test_count); \
for (int _i = 0; _i < test_index; _i++) { \
int _before = fail_count; \
printf("[%s] ", tests[_i].name); \
tests[_i].func(); \
if (fail_count == _before) printf("PASS\n"); \
} \
printf("\n─── Summary ───\n"); \
printf("%d tests, %d passed, %d failed\n", \
test_count, test_count - fail_count, fail_count); \
} while(0)
/* 测试用例 */
TEST(add) {
ASSERT_EQ(1 + 1, 2);
ASSERT_EQ(100 - 1, 99);
ASSERT_EQ(3 * 7, 21);
}
TEST(str) {
ASSERT_STREQ("hello", "hello");
ASSERT_STREQ("", "");
ASSERT_STREQ("test", "test");
}
TEST(fail) {
ASSERT_EQ(2 + 2, 5); /* 故意失败 */
ASSERT_EQ(1, 1); /* 不会执行 */
}
TEST(null) {
ASSERT_STREQ(NULL, NULL); /* 通过 */
ASSERT_STREQ(NULL, "hello"); /* 失败 */
}
TEST(float_test) {
ASSERT_EQ((int)3.14, 3); /* 通过 */
ASSERT_EQ((int)2.718, 3); /* 失败 */
}
int main(void) {
register_test(test_add);
register_test(test_str);
register_test(test_fail);
register_test(test_null);
register_test(test_float_test);
RUN_TESTS();
return 0;
}完整流程:5 个 TEST 宏生成 5 个 static 函数 → register_test 填入数组 → RUN_TESTS() 遍历调用函数指针,对比 fail_count 变化判断 PASS/FAIL → 打印汇总。
对照检查:ASSERT_EQ 中
#a对应表达式的文本而非值吗?ASSERT_STREQ 的 NULL 判断用的是 XOR!=而非||吗?TEST 宏用了##记号粘贴吗?RUN_TESTS 中 PASS/FAIL 的判断基于fail_count变化吗?
课堂讨论
- ASSERT_EQ 宏中的
#a打印的是表达式文本还是运行时值?如果#a能打印运行时值,那它怎么做到在编译阶段就知道运行时的结果? - 为什么 ASSERT_EQ 参数中的
x++会导致不可预测的行为?展开宏追踪 x 被求值了几次。 do { ... } while(0)中的循环真的会执行吗?如果不会,它存在的意义是什么?编译器如何处理它?- 如果 ASSERT_EQ 失败后调用
exit(1)而不是return,RUN_TESTS 的行为会怎样?对调试有什么影响? - ASSERT_STREQ(NULL, NULL) 为什么应该通过?如果定义一个"两个都为空就是错误"的语义,测试代码要怎么写?
- 能否让 TEST 宏自动注册,省去手动调用
register_test?在标准 C 中存在可行的纯标准方案吗? - 这个框架和 Google Test / Unity / Check 等成熟框架的核心差距在哪里?
讨论答案
Q1: #a 打印的是表达式文本还是运行时值?
#a 打印的是源代码中的表达式文本,不*而非运行时值。预处理器在编译之前工作——宏展开阶段,#a 将传入的源码 token 序列括上双引号转为字符串字面量。例如 ASSERT_EQ(2 + 2, 5) 展开后,#a 变成 "2 + 2",存储在二进制的 .rodata 段。
验证方式——用 gcc -E 查看预处理输出:
$ cat test.c
#define PRINT(x) printf("%s\n", #x)
PRINT(a + b);
$ gcc -E test.c
printf("%s\n", "a + b"); // #a 在预处理阶段就已固定#a 不能打印运行时值,因为预处理阶段程序还没运行。这也是 ASSERT_EQ 需要同时 (int)(a) 的原因。
Q2: 为什么 x++ 在宏中会导致不可预测行为?
以 ASSERT_EQ(x++, 5) 展开为例——参数 a 被替换为 x++ 后,宏体中每处 (a) 和 (int)(a) 都会让 x 递增一次。如果 x 初始为 0,if ((x++) != (5)) 比较后 x 变为 1,随后 printf 中的 (int)(a) 又让 x 变为 2——调用者期望 x 只递增 1 次,实际递增了至少 2 次。
这就是宏参数的"重复求值"问题(double evaluation)。括号 (a) 是防御性编程的最后防线,但不能消除副作用的根本问题。实用建议:永远不要在断言宏中传入用带副作用的表达式——先存到变量中:int val = x++; ASSERT_EQ(val, 5);
Q3: do-while(0) 的循环会执行吗?意义何在?
do { ... } while(0) 中的循环体恰好执行一次,永远不会循环。它的意义完全在语法层面:将多条语句包装为"一条完整语句",使其能安全地出现在任何需要单条语句的上下文中(如 if/else)。编译器能识别这一惯用法并完全消除 do-while 结构——生成的机器码等同于直接顺序调用语句。这是"零成本抽象"在预处理器层面的极致体现。
Q4: 用 exit(1) 替换 return 的影响
exit(1) 会立即终止整个进程,RUN_TESTS 循环将被打断——第一个失败的测试之后的所有测试都不会执行。return 只退出当前 TEST 函数,控控制权回到 RUN_TESTS 循环,确保所有测试都有机会运行。在 CI 环境中,构建日志需要包含所有失败信息,而不是遇到第一个失败就停止。return 让你看到完整的失败列表——这对调试至关重要。
Q5: 双 NULL 为什么应该通过?
从语义角度看,两个 NULL 指针表示"两个都不存在的字符串",它们的状态完全相同——这符合""相等"的直觉。如果双 NULL 被判定为不相等,测试代码中必须写额外的 NULL 检查才能安全使用 ASSERT_STREQ,降低了宏的可用性。针(双 NULL 视为通过,让常见场景简单——这是框架的可用性设计考量。
Q6: 能否在标准 C 中自动注册 TEST?
在纯标准 C(C89/C99/C11)中,不存在在 main() 之前自动执行代码的机制。C++ 有全局构造函数,C 没有——这是 C 语言的哲学选择:不给程序添加"看不见"的执行流程。
两种变通方案:(1) GCC 扩展 __attribute__((constructor)) 让函数在 main 之前执行,丧失可移植性;(2) X-Macro 模式:将测试名集中在头文件中用预处理器批量生成,缺点是不直观。手动注册虽繁琐但简单、可移植、透明。
Q7: 与成熟框架的核心差距
| 维度 | 本框架 | Google Test / Unity / Check |
|---|---|---|
| 测试发现 | 手动 register_test | 自动(宏 + 全局注册) |
| 测试夹具 | 无 | SetUp/TearDown |
| 参数化测试 | 无 | TEST_P / value-parameterized |
| 断言类型 | 2 种(整数 + 字符串) | 几十种 |
| 报告格式 | 纯文本 printf | XML/JSON/TAP 多格式 |
| 代码行数 | < 100 行 | 数万行 |
这些差距恰恰是本框架的教学价值——规模小到可以完全理解。理解本框架后再看成熟框架源码,会发现内核设计一脉相承:宏生成测试函数、函数指针注册表、断言失败时 return——这些基本机制依然存在,只是层层封装了更多功能。
课后练习
实现浮点断言宏。编写
ASSERT_DOUBLE_EQ(a, b, epsilon)宏,使用fabs(a - b) < epsilon进行容差比较。验证ASSERT_DOUBLE_EQ(0.1 + 0.2, 0.3, 1e-9)通过。知识点提示:
fabs来自<math.h>,需-lm链接。%g格式符自动选择%f或%e中最紧凑的表示。参考解答
c#include <stdio.h> #include <math.h> static int fail_count = 0; #define ASSERT_DOUBLE_EQ(a, b, eps) do { \ double _da = (a), _db = (b); \ if (fabs(_da - _db) > (eps)) { \ printf(" FAIL %s:%d: ASSERT_DOUBLE_EQ(%s, %s) " \ "— expected %g, got %g (eps=%g)\n", \ __FILE__, __LINE__, #a, #b, _db, _da, (double)(eps)); \ fail_count++; return; \ } \ } while(0) int main(void) { ASSERT_DOUBLE_EQ(0.1 + 0.2, 0.3, 1e-9); /* 通过 */ ASSERT_DOUBLE_EQ(0.1 + 0.2, 0.4, 1e-9); /* 失败 */ printf("fail_count = %d (expected 1)\n", fail_count); return 0; }实现测试过滤功能。扩展 RUN_TESTS,支持命令行参数过滤。如果程序以
./test_framework test_add运行,只执行名为 "test_add" 的测试。知识点提示:
main(int argc, char *argv[])获取命令行参数。strcmp(tests[i].name, argv[1])比较名称。argc == 1时运行全部。参考解答
cvoid run_tests_filtered(int argc, char *argv[]) { test_count = test_index; const char *filter = (argc > 1) ? argv[1] : NULL; if (filter) printf("Running tests matching \"%s\"...\n\n", filter); else printf("Running %d test(s)...\n\n", test_count); int ran = 0; for (int i = 0; i < test_index; i++) { if (filter && strcmp(tests[i].name, filter) != 0) continue; ran++; int before = fail_count; printf("[%s] ", tests[i].name); tests[i].func(); if (fail_count == before) printf("PASS\n"); } printf("\n─── Summary ───\n"); printf("%d tests, %d passed, %d failed\n", ran, ran - fail_count, fail_count); }实现 ASSERT_TRUE 和 ASSERT_FALSE。添加两个通用布尔断言宏。ASSERT_TRUE(expr) 检查表达式为非零,ASSERT_FALSE(expr) 检查表达式为零。
知识点提示:
#expr打印条件表达式文本。失败输出:ASSERT_TRUE(cond) — condition is false。参考解答
c#define ASSERT_TRUE(cond) do { \ if (!(cond)) { \ printf(" FAIL %s:%d: ASSERT_TRUE(%s) — condition is false\n", \ __FILE__, __LINE__, #cond); \ fail_count++; return; \ } \ } while(0) #define ASSERT_FALSE(cond) do { \ if (cond) { \ printf(" FAIL %s:%d: ASSERT_FALSE(%s) — condition is true\n", \ __FILE__, __LINE__, #cond); \ fail_count++; return; \ } \ } while(0) int is_even(int x) { return x % 2 == 0; } TEST(bool) { ASSERT_TRUE(is_even(4)); ASSERT_FALSE(is_even(5)); }
参考资料
- GCC Preprocessor Manual —
#(Stringification) 和##(Concatenation) 的官方文档: https://gcc.gnu.org/onlinedocs/cpp/ - The C Programming Language (K&R) — 第 4.11.2 节 Macro Substitution,第 5.11 节 Pointers to Functions
- do { ... } while(0) 惯用法 — 多语句宏的经典解释: https://stackoverflow.com/questions/154136/
- Unity Test Framework — 轻量级 C 单元测试框架,设计理念一脉相承: http://www.throwtheswitch.org/unity
- Test-Driven Development: By Example (Kent Beck) — TDD 红-绿-重构循环的经典著作
- IEEE 754 浮点运算 — 理解浮点比较为何需要容差: https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
"Make it work, make it right, make it fast." — Kent Beck