182 lines
12 KiB
Markdown
182 lines
12 KiB
Markdown
|
# 29 | 尝试升级(下):“链表”知识在测试框架中的应用
|
|||
|
|
|||
|
你好,我是胡光,欢迎回来。
|
|||
|
|
|||
|
上节课中,我们通过参考 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 就是我们对于代码的优化过程。这是一个追求极致、不断优化项目的过程。
|
|||
|
|
|||
|
关于测试框架开发的讲解内容虽然结束了,但我希望这几节课可以成为你优化这份代码的一个起点。日后,你可以选择增加额外的功能,修改实现的架构,甚至是使用不同的语言重新进行实现,哪怕是一个小小的改动,都是值得称赞的。
|
|||
|
|
|||
|
在下节课,我将指导你完成一个简易的计算器程序,也算是给我们这个课程一个圆满的结束。
|
|||
|
|
|||
|
好了,今天就到这里了,我是胡光,我们下一节课见。
|
|||
|
|