你好,我是胡光,欢迎回来。

上节课中,我们通过参考 gtest 的输出,完善了我们自己的测试框架的输出信息,也就是添加了测试用例的名称、运行结果以及运行时间。并且,我提到了在一般情况下,项目中的功能开发原则:功能迭代,数据先行。就是要开发新的功能之前,我们应该先考虑清楚实现这部分功能相关的数据,在系统中的存储与使用的情况,只有这样,才能更好地完成功能的实现与迭代优化。

今天迎来我们整个测试框架项目的最后一节课。这节课的目的,一是对前几节课内容进行一个总结,二是向你说明我们现在开发的测试框架代码,其实还有很多优化的空间。至于这个优化空间是什么呢?这次我将带着你结合之前学习的“链表”知识,对测试框架进行一个具体的优化改进。

关于测试框架的优化,是一个不断学习的过程。在这个过程中,你深刻体会到“知不足,然后能自反也”这句话的含义。就是在优化代码的过程中,你会发现自己的不足,然后努力提高自己的能力去弥补不足;当你提升了自己之后,你又会看到自己在其他方面的不足,进而继续提高自己。

好了,废话不多说,我们正式开始今天的学习。

揭晓答案:EXPECT_EQ 宏究竟是如何实现的

在对测试框架进行优化之前呢,我先来回答一下,可能困扰了你两节课的一个问题:就是 EXPECT_EQ 宏究竟是如何实现的?这个问题的答案呢,我给出一个可行的实现,仅供参考。

首先,EXPECT_EQ(a, b) 在a,b 两部分值相等的时候,不会产生额外的输出信息,而当 a,b 两部分不相等的时候,就会输出相应的提示信息。如下所示:

gtest_test.cpp:25: Failure
Expected equality of these values:
  is_prime(4)
    Which is: 1
  0

这段输出信息,对应的是源代码中的 “EXPECT_EQ(is_prime(4), 0); ”的输出。如你所见,第 1 行的输出内容包含了源文件名(gtest_test.cpp),EXPECT_EQ 宏所在的代码位置(25),以及一个提示结果(Failure)。

接下来的信息,你自己就可以看懂了,就是关于 EXPECT_EQ 传入两部分的值。对于函数调用部分,EXPECE_EQ 会输出这个函数的调用形式及返回值信息,也就是输出中的 “is_prime(4)”“Which is: 1” 这段内容。而对于数值信息,只会输出数值信息本身,也就是输出信息中第 5 行的那个 0。

实际上,要想在宏中实现类似于这种根据传入参数类型,选择输出形式的功能,对于现在的你来说可能有点困难。所以,我们可以重新设计一种输出形式,只要能够清晰地展示错误信息就可以。

重新设计的输出提示,如下所示:

gtest_test.cpp:25: Failure
Expected (is_prime(4) == 0):
    Which is: (1 == 0)

修改完以后的输出信息,你可以看到,第 2 行就是传入 EXPECT_EQ 宏两部分的比较,第 3 行是这两部分实际输出值的比较。

重新设计了输出信息以后,就可以来看看 EXPECT_EQ 宏的实现了:

#define EXPECT(a, b, comp) { \
    __typeof(a) val_a = (a), val_b = (b); \
    if (!(val_a comp val_b)) { \
        printf("%s:%d: Failure\n", __FILE__, __LINE__); \
        printf("Expected (%s %s %s):\n", #a, #comp, #b); \
        printf("    Which is: (%d %s %d)\n", val_a, #comp, val_b); \
        test_run_flag = 0; \
    } \
}
#define EXPECT_EQ(a, b) EXPECT(a, b, ==)
#define EXPECT_LT(a, b) EXPECT(a, b, <)
#define EXPECT_GT(a, b) EXPECT(a, b, >)
#define EXPECT_NE(a, b) EXPECT(a, b, !=)

在这段实现中,你会发现,我们不仅实现了 EXPECT_EQ,还额外实现了EXPECT_LT、EXPECT_GT、EXPECT_NE 等用于比较的宏。其中,LT 是英文 little 的缩写,是判断小于关系的;GT 是 great 的缩写,是判断大于关系的;NE 是 not equal 的缩写,是判断不等于关系的。而这些所有的宏,都是基于 EXPECT 宏实现的。

我们将用于比较的运算符,当作参数传递给 EXPECT 宏。有了 EXPECT 宏以后,你就可以参考代码中的第10~13行的内容,轻松地扩展出用于小于等于或者大于等于的宏了。由于 EXPECT 宏的实现,全都是我们之前学习过的知识点,所以在这里我就不再赘述了,你可以自行阅读文稿中的代码。

用链表存储测试用例

看完了 EXPECT 宏的参考实现以后,整个测试框架的基础功能,就算是彻底搭建完成了。

接下来,我们再重新审视下面这段函数指针数组 test_function_arr 的代码设计,来思考一下这个测试框架中还有没有可以优化的地方。

struct test_function_info_t {
    test_function_t func;  // 测试用例函数指针,指向测试用例函数
    const char *name; // 指向测试用例名称
} test_function_arr[100];
int test_function_cnt = 0;

这段代码中,我们使用了数组来定义存储测试函数信息的存储区,这个数组的大小有 100 位,也就是说,最多可以存储 100 个测试用例函数信息。

那我们来思考一个问题:要是有程序中定义了 1000 个测试用例,怎么办呢?毕竟,对于中型项目开发来说,定义 1000 个测试用例,可不是什么难事儿。这个时候,你可能会说,那简单啊,数组大小设置成 10000 不就行了。

但是你要明白,这种设计尽管简单粗暴且有效,可它一点儿程序设计的美感都没有。什么意思呢?就是当我们为测试用例准备了10000个数组空间的时候,可能在真正的开发过程中,只定义了 2 个测试用例,这就会浪费掉 9998 个数组空间。

更形象地描述这种行为的话,这种设计方式很像计划经济,计划多少用多少。同时,它的弊端也很明显,一旦计划不好,要不是造成空间浪费,要不就是资源紧张。

所以,我们应该尝试着从“计划经济”向“市场经济”转变一下,可不可以转变成想用多少就生产多少。那应该怎么做呢?

我们知道,在程序中数组的空间大小,是需要提前计划出来的。但是有一种结构的空间,是可以动态增加或减少的,那就是我们之前讲过的“链表”结构。你想一下,如果我们把一个一个的测试函数信息,封装成一个一个的链表节点,每当增加一个测试用例的时候,就相当于向整个链表中插入一个新的节点。此时,用链表实现的存储测试函数信息的结构,它所占空间大小就和实际测试用例的数量成正比了。这就是我说的用多少,就生产多少。

下面,我们就来说说具体怎么做。

第一步,我们需要改变 test_function_info_t 的结构定义,也就是把原先存储测试用例函数信息的结构体类型,改装成链表结构。最简单的方法,就是在结构体的定义中,增加一个指针字段,指向下一个 test_function_info_t 类型的数据,代码如下所示:

struct test_function_info_t {
    test_function_t func;  // 测试用例函数指针,指向测试用例函数
    const char *name; // 指向测试用例名称
    struct test_function_info_t *next;
};
struct test_function_info_t head, *tail = &head;

可以看到,我们给 test_function_info_t 结构体类型增加了一个链表中的 next 字段,除此之外,我们还定义了一个虚拟头节点 head 和一个指针变量 tail。这里你需要注意,head 是虚拟头节点,后续我们会向 head 所指向链表中插入链表节点,tail 指针则指向了整个链表的最后一个节点的地址。

第二步,在准备好了数据存储结构以后,需要改写的就是函数注册的逻辑了。在改写 TEST 宏中的注册函数逻辑之前呢,我们先准备一个工具函数 add_test_function,这个工具函数的作用,就是根据传入的参数,新建一个链表节点,并且插入到整个链表的末尾:

void add_test_function(const char *name, test_function_t func) {
    struct test_function_info_t *node;
    node = (struct test_function_info_t *)malloc(sizeof(struct test_function_info_t));
    node->func = func;
    node->name = name;
    node->next = NULL;
    tail->next = node;
    tail = node;
    return ;
}

好了, add_test_function 工具函数准备好之后,我们正式来改写 TEST 宏中注册函数的逻辑。其实难度也不大,也就是要求注册函数调用 add_test_function 函数,并且传入相关的测试用例的函数信息即可:

#define TEST(test_name, func_name) \
void test_name##_##func_name(); \
__attribute__((constructor)) \
void register_##test_name##_##func_name() { \
    add_test_function(#func_name "." #test_name, \
                      test_name##_##func_name); \
} \
void test_name##_##func_name()

最后一步,处理完了数据写入的过程以后,来让我们修改一下使用这份数据的代码逻辑,那就是 RUN_ALL_TESTS 函数中的相关逻辑。之前,RUN_ALL_TESTS 函数中,循环遍历数组中的每一个测试用例,并且执行相关的测试用例函数,对这一部分,修改成针对于链表结构的遍历方式即可,代码如下所示:

int RUN_ALL_TESTS() {
    struct test_function_info_t *p = head.next;
    for (; p; p = p->next) {
        printf("[ RUN      ] %s\n", p->name);
        test_run_flag = 1;
        long long t1 = clock();
        p->func();
        long long t2 = clock();
        if (test_run_flag) {
            printf("[       OK ] ");
        } else {
            printf("[  FAILED  ] ");
        }
        printf("%s", p->name);
        printf(" (%.0lf ms)\n\n", 1.0 * (t2 - t1) / CLOCKS_PER_SEC * 1000);
    }
    return 0;
}

这样,我们就彻底完成了测试用例函数信息存储部分的“链表”改造过程。

对于上面的这份代码实现,你会发现,链表节点空间是通过 malloc 函数动态申请出来的,可在我们的程序中,并没有对这些空间使用 free 进行释放,如果你想让这个程序对空间的申请与回收做到有始有终,变得更加干净,那应该怎么办呢?

这里你可以借助 __attribute__((destructor)) 的功能,之前我们介绍了一个__attribute__((constructor))的作用是让函数先于主函数执行,而destructor 就是使函数在主函数结束以后才执行的函数特性设置。有了这个特性设置,你可以实现一个函数,专门用来销毁测试函数链表所占存储空间,这样在逻辑上,你的程序会变得更完美。当然,你即使不这么做,也不会影响到原有的程序功能的正确性。

项目小结

至此,我们关于测试框架开发的内容,就算是告一段落了。

从26讲到29讲,我们经历了一个项目从 0 到 1 的过程,继而又完成了项目从 1 到 1.5 的升级。所谓从 0 到 1 就是项目从最初的想法变成代码的过程,从 1 到 1.5 就是我们对于代码的优化过程。这是一个追求极致、不断优化项目的过程。

关于测试框架开发的讲解内容虽然结束了,但我希望这几节课可以成为你优化这份代码的一个起点。日后,你可以选择增加额外的功能,修改实现的架构,甚至是使用不同的语言重新进行实现,哪怕是一个小小的改动,都是值得称赞的。

在下节课,我将指导你完成一个简易的计算器程序,也算是给我们这个课程一个圆满的结束。

好了,今天就到这里了,我是胡光,我们下一节课见。

评论