第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报告,你会看到每个函数的覆盖情况。我一般盯着那些红色(未覆盖)的行看,问自己三个问题:

  1. 这行代码在什么条件下才会执行?
  2. 我有没有写对应的测试用例?
  3. 如果没写,是因为遗漏了,还是这行代码确实不可达?

举个例子,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