第30章 综合实战:从零开始为一个开源C项目(如cJSON)编写完整的单元测试套件
讲到这里,终于到了我们最过瘾的一章。前面二十多章我们聊了各种测试框架、桩函数、打桩技巧、覆盖率分析……但说实话,光说不练假把式。今天我们就拿一个真实的开源项目——cJSON,从零开始给它搭一套完整的单元测试套件。
为什么选cJSON?原因很简单:它够小、够经典、够实用。cJSON只有两个文件,两千多行代码,但功能完整,解析、打印、数组、对象、嵌套全都有。我在项目中至少见过七八个团队直接拿它做嵌入式JSON解析,可以说它是C语言开源项目里的一颗明珠。
核心目标:为cJSON的每个公开API编写测试用例,覆盖正常路径、边界条件、异常输入,最终达到90%以上的行覆盖率。
30.1 先摸清cJSON的家底
写测试之前,我习惯先做一件事:把被测模块的API清单列出来。你想想看,连被测对象有哪些接口都不清楚,测试怎么可能写得全?
cJSON的公开API大概分这么几类:
| 类别 | API示例 | 数量 |
|---|---|---|
| 创建/销毁 | cJSON_CreateObject, cJSON_Delete | 约8个 |
| 解析/打印 | cJSON_Parse, cJSON_Print | 约6个 |
| 添加/替换 | cJSON_AddItemToObject, cJSON_ReplaceItem | 约10个 |
| 查找/遍历 | cJSON_GetObjectItem, cJSON_GetArrayItem | 约6个 |
| 工具函数 | cJSON_IsInvalid, cJSON_GetStringValue | 约5个 |
嗯,一共三十多个API。看起来不多,但每个API都有好几个分支路径要测。我建议你按这个分类来组织测试文件,一个类别一个测试文件,这样后期维护起来清爽很多。
30.2 搭建测试工程骨架
我个人习惯用CUnit做测试框架,配合gcov做覆盖率分析。目录结构长这样:
cjson_test/
├── src/ # 被测源码
│ ├── cJSON.c
│ └── cJSON.h
├── test/ # 测试代码
│ ├── test_create.c
│ ├── test_parse.c
│ ├── test_print.c
│ ├── test_add.c
│ ├── test_find.c
│ └── test_utils.c
├── include/ # 测试辅助头文件
│ └── test_common.h
├── Makefile
└── run_tests.sh # 一键运行脚本
这里有个小细节:我把cJSON的源码直接复制到src目录下,而不是用符号链接。为什么?因为有时候测试需要修改源码中的某些静态函数或宏定义,用符号链接容易改错地方。我曾经吃过这个亏,改完测试发现源码也被改了,差点把生产代码搞崩。
30.3 第一个测试:cJSON_CreateObject
好,我们开始写第一个测试用例。从最简单的创建函数入手:
#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>
#include "cJSON.h"
void test_create_object(void)
{
cJSON *obj = cJSON_CreateObject();
CU_ASSERT_PTR_NOT_NULL(obj);
CU_ASSERT_EQUAL(obj->type, cJSON_Object);
CU_ASSERT_PTR_NULL(obj->child);
CU_ASSERT_PTR_NULL(obj->next);
CU_ASSERT_PTR_NULL(obj->prev);
cJSON_Delete(obj);
}
void test_create_string(void)
{
cJSON *str = cJSON_CreateString("hello");
CU_ASSERT_PTR_NOT_NULL(str);
CU_ASSERT_EQUAL(str->type, cJSON_String);
CU_ASSERT_STRING_EQUAL(str->valuestring, "hello");
cJSON_Delete(str);
}
void test_create_number(void)
{
cJSON *num = cJSON_CreateNumber(3.14);
CU_ASSERT_PTR_NOT_NULL(num);
CU_ASSERT_EQUAL(num->type, cJSON_Number);
CU_ASSERT_DOUBLE_EQUAL(num->valuedouble, 3.14, 0.001);
cJSON_Delete(num);
}
看到没?每个测试用例都遵循"创建-断言-清理"三步走。这里我特别强调一下:一定要在测试结束时调用cJSON_Delete释放内存。不然跑完几千个测试用例,内存泄漏能把你的开发机搞死。
小技巧:可以用CU_ASSERT_DOUBLE_EQUAL来比较浮点数,第三个参数是精度。直接用==比较浮点数?那是给自己挖坑。
30.4 解析测试:这才是重头戏
cJSON_Parse是整个库的核心,也是最容易出bug的地方。我当年在一个物联网项目里用cJSON解析设备上报的数据,结果有个设备发了个空字符串,cJSON_Parse直接返回NULL,但我的代码没做空指针检查……嗯,那晚的线上事故让我记忆犹新。
所以解析测试,我们要覆盖这些场景:
- 正常JSON:简单对象、嵌套对象、数组、混合结构
- 边界JSON:空对象"{}"、空数组"[]"、空字符串""
- 异常JSON:缺少引号、多余逗号、花括号不匹配、非法字符
- 超大JSON:深层嵌套(比如100层)、超长字符串(比如10万个字符)
来看一个典型的解析测试:
void test_parse_normal_object(void)
{
const char *json = "{\"name\":\"cJSON\",\"version\":1.0}";
cJSON *root = cJSON_Parse(json);
CU_ASSERT_PTR_NOT_NULL(root);
cJSON *name = cJSON_GetObjectItem(root, "name");
CU_ASSERT_PTR_NOT_NULL(name);
CU_ASSERT_STRING_EQUAL(name->valuestring, "cJSON");
cJSON *ver = cJSON_GetObjectItem(root, "version");
CU_ASSERT_PTR_NOT_NULL(ver);
CU_ASSERT_DOUBLE_EQUAL(ver->valuedouble, 1.0, 0.001);
cJSON_Delete(root);
}
void test_parse_invalid_json(void)
{
const char *bad_json = "{name:}"; // 缺少引号
cJSON *root = cJSON_Parse(bad_json);
CU_ASSERT_PTR_NULL(root); // 应该解析失败
const char *error_ptr = cJSON_GetErrorPtr();
CU_ASSERT_PTR_NOT_NULL(error_ptr);
// 可以进一步检查错误位置
}
注意:cJSON_Parse失败时返回NULL,但不会设置errno。一定要用cJSON_GetErrorPtr获取错误位置,否则你根本不知道解析在哪一步挂掉的。
30.5 打印测试:反向验证
打印函数cJSON_Print是解析的逆操作。我的测试策略很简单:先解析一段JSON,打印出来,再解析打印结果,对比两次解析是否一致。这叫"往返测试"(round-trip test)。
void test_print_round_trip(void)
{
const char *original = "{\"name\":\"cJSON\",\"version\":1.0,\"tags\":[\"light\",\"fast\"]}";
cJSON *root = cJSON_Parse(original);
CU_ASSERT_PTR_NOT_NULL(root);
char *printed = cJSON_Print(root);
CU_ASSERT_PTR_NOT_NULL(printed);
cJSON *root2 = cJSON_Parse(printed);
CU_ASSERT_PTR_NOT_NULL(root2);
// 比较两个JSON树是否等价
int equal = cJSON_Compare(root, root2, 1);
CU_ASSERT_TRUE(equal);
cJSON_Delete(root);
cJSON_Delete(root2);
free(printed);
}
这个测试看起来简单,但能发现很多隐蔽的问题。比如浮点数精度丢失、字符串转义错误、数组顺序变化等等。我在项目中用这个套路抓到过至少三个bug。
30.6 覆盖率分析与持续优化
测试写完了,怎么知道测得全不全?上gcov。在Makefile里加上编译选项:
CFLAGS = -fprofile-arcs -ftest-coverage -O0 -g
LDFLAGS = -fprofile-arcs
跑完测试后执行:
gcov cJSON.c
lcov -c -d . -o coverage.info
genhtml coverage.info -o coverage_report
打开生成的HTML报告,你会看到每个函数的覆盖情况。我一般盯着那些红色(未覆盖)的行看,问自己三个问题:
- 这行代码在什么条件下才会执行?
- 我有没有写对应的测试用例?
- 如果没写,是因为遗漏了,还是这行代码确实不可达?
举个例子,cJSON里有个处理转义字符的函数,里面有个switch-case处理'\n'、'\t'、'\\'等。我第一次跑覆盖率时发现'\r'的分支没覆盖到。赶紧补了一个包含回车符的JSON字符串测试用例。
30.7 测试套件的整体结构
最后,我们把所有测试用例注册到CUnit的suite里:
int main(void)
{
CU_pSuite pSuite = NULL;
if (CUE_SUCCESS != CU_initialize_registry())
return CU_get_error();
// 创建测试套件
pSuite = CU_add_suite("cJSON_Create_Suite", NULL, NULL);
CU_add_test(pSuite, "test_create_object", test_create_object);
CU_add_test(pSuite, "test_create_string", test_create_string);
CU_add_test(pSuite, "test_create_number", test_create_number);
pSuite = CU_add_suite("cJSON_Parse_Suite", NULL, NULL);
CU_add_test(pSuite, "test_parse_normal_object", test_parse_normal_object);
CU_add_test(pSuite, "test_parse_invalid_json", test_parse_invalid_json);
// ... 更多测试
CU_basic_set_mode(CU_BRM_VERBOSE);
CU_basic_run_tests();
CU_cleanup_registry();
return CU_get_error();
}
跑一下看看效果:
$ ./run_tests.sh
CUnit - A unit testing framework for C - Version 2.1-3
http://cunit.sourceforge.net/
Suite: cJSON_Create_Suite
Test: test_create_object ... passed
Test: test_create_string ... passed
Test: test_create_number ... passed
Suite: cJSON_Parse_Suite
Test: test_parse_normal_object ... passed
Test: test_parse_invalid_json ... passed
...
Run Summary: Type Total Ran Passed Failed
suites 2 2 n/a 0
tests 12 12 12 0
asserts 48 48 48 0
看到12个测试全部通过,48个断言全部成功,心里踏实多了。但说实话,这只是开始。真正的测试工作是在项目迭代中不断补充用例,每次修bug先加测试,每次加功能先写测试。养成这个习惯,你的代码质量会有质的飞跃。
本章核心要点:
- 按API分类组织测试文件,一个类别一个文件
- 每个测试用例遵循"创建-断言-清理"三步走
- 解析测试要覆盖正常、边界、异常三大类
- 用往返测试验证打印函数的正确性
- 用gcov分析覆盖率,盯着未覆盖的行补用例
- 测试套件要持续维护,和代码一起演进
公众号:蓝海资料掘金营,微信deep3321