跳转到内容

Lesson 60: 微型 C 测试框架

练习任务

难度:中

用纯 C 预处理器宏实现一个微型单元测试框架。你需要完成四个核心宏和五个自测用例:

  1. ASSERT_EQ(a, b) — 整数相等断言,失败时打印文件、行号、期望值和实际值
  2. ASSERT_STREQ(a, b) — 字符串符串相等断言,正确处理 NULL 指针(双 NULL 视为相等)
  3. TEST(name) { ... } — 用 ## 记号粘贴生成 test_##name 函数
  4. RUN_TESTS() — 运行所有注册的测试,打印 PASS/FAIL 状态和汇总

你需要写五个测试用例验证框架正确性,其中 test_failtest_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,剩余断言不再执行
  • return vs exit 的语义选择 — 退出测试而非终止进程,确保后续测试继续运行
  • 浮点断言的局限 — 整数断言无法精确比较浮点数,成熟框架需引入容差 epsilon

代码框架

60_micro_test_framework.c
c
#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) ## 如何把 testadd 变成 test_add?(3) 为什么 ASSERT_STREQ(NULL, NULL) 应该 PASS?


深度讲解

1. C 预处理器核心操作符——测试框架的基础设施

1.1 # — 字符串化操作符 (Stringification)

# 操作符在宏展开阶段将参数转数转换为 C 字符串字面量。它拿到的是参数的源码文本,不是运行时值。

stringify_demo.c
c
#define STR(x) #x

STR(hello)        // → "hello"
STR(1 + 2)        // → "1 + 2"    ← 注意: 是表达式文本, 不是 "3"!
STR(assert_eq)    // → "assert_eq"

在 ASSERT_EQ 中,这一特性决定了打印内容:

c
// 源码: 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_addtest_str 等动态函数名:

token_pasting_demo.c
c
#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 4

2.3 括号保护——防御性编程的铁律

paren_protection.c
c
/* 不加括号的灾难: */
#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_safe_compare.c
c
/* 错误——双 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 局部变量防重复求值

c
/* 为什么不能直接写 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++ 然后 returnreturn 从当前 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) 强制转换比较浮点数:

c
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)比较:

float_assert.c
c
#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 宏实现
solution_60_assert_eq.c
c
#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 安全)
solution_60_assert_streq.c
c
#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
solution_60_test_macro.c
c
#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 变化吗?


课堂讨论

  1. ASSERT_EQ 宏中的 #a 打印的是表达式文本还是运行时值?如果 #a 能打印运行时值,那它怎么做到在编译阶段就知道运行时的结果?
  2. 为什么 ASSERT_EQ 参数中的 x++ 会导致不可预测的行为?展开宏追踪 x 被求值了几次。
  3. do { ... } while(0) 中的循环真的会执行吗?如果不会,它存在的意义是什么?编译器如何处理它?
  4. 如果 ASSERT_EQ 失败后调用 exit(1) 而不是 return,RUN_TESTS 的行为会怎样?对调试有什么影响?
  5. ASSERT_STREQ(NULL, NULL) 为什么应该通过?如果定义一个"两个都为空就是错误"的语义,测试代码要怎么写?
  6. 能否让 TEST 宏自动注册,省去手动调用 register_test?在标准 C 中存在可行的纯标准方案吗?
  7. 这个框架和 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 种(整数 + 字符串)几十种
报告格式纯文本 printfXML/JSON/TAP 多格式
代码行数< 100 行数万行

这些差距恰恰是本框架的教学价值——规模小到可以完全理解。理解本框架后再看成熟框架源码,会发现内核设计一脉相承:宏生成测试函数、函数指针注册表、断言失败时 return——这些基本机制依然存在,只是层层封装了更多功能。


课后练习

  1. 实现浮点断言宏。编写 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 中最紧凑的表示。

    参考解答
    ex1_float_assert.c
    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;
    }
  2. 实现测试过滤功能。扩展 RUN_TESTS,支持命令行参数过滤。如果程序以 ./test_framework test_add 运行,只执行名为 "test_add" 的测试。

    知识点提示main(int argc, char *argv[]) 获取命令行参数。strcmp(tests[i].name, argv[1]) 比较名称。argc == 1 时运行全部。

    参考解答
    ex2_test_filter.c
    c
    void 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);
    }
  3. 实现 ASSERT_TRUE 和 ASSERT_FALSE。添加两个通用布尔断言宏。ASSERT_TRUE(expr) 检查表达式为非零,ASSERT_FALSE(expr) 检查表达式为零。

    知识点提示#expr 打印条件表达式文本。失败输出:ASSERT_TRUE(cond) — condition is false

    参考解答
    ex3_assert_bool.c
    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));
    }

参考资料

"Make it work, make it right, make it fast." — Kent Beck

Released under the MIT License.