完善测试框架的功能与提示
温故知新,gtest 的输出结果
第一部分,一套单元测试的相关信息:
1 | [==========] Running 2 tests from 1 test suite. |
第二部分,是每个单元测试运行信息的输出:
1 | [ RUN ] test_is_prime.test1 |
如上所示,第一个单元测试 test_is_prime.test1 运行结果正确,所用时间是 1ms;第二个单元测试 test_is_prime.test2 中,有三个判等 EXPECT 断言的结果是错误的,也就是 is_prime 函数的返回值,和测试用例中期望的返回值不符,这说明 is_prime 函数存在 Bug。
第三部分,就是这套单元测试的总结信息,以及整个程序单元测试结果的汇总信息。这段信息,有兴趣的小伙伴可以自己理解着看一下,由于不是咱们今天课程的重点,就不展开介绍了。
1 | [----------] 2 tests from test_is_prime (1 ms total) |
从哪些方面来完善测试框架?
通过观察第二部分的输出,我们基本要从三个方面完善测试框架的输出信息。
- 在每个测试用例运行之前,要先行输出相关测试用例的名字;
- 每个测试用例运行结束以后,要输出测试用例的运行时间与运行结果(OK 或者 FAILED);
- 若测试用例中的 EXPECT 断言出错,需要输出错误提示信息。
测试用例的名字输出
首先是如何输出测试用例的名字。我们先回忆一下上节课设计的注册函数,如下所示:
1 |
注册函数是随着 TEST 展开的,从展开的代码逻辑中可以看到,它只是将测试用例的函数地址记录在了函数指针数组中。要想 RUN_ALL_TESTS 函数后续能够输出测试用例的函数名称的话,我们只需要修改注册函数的功能逻辑即可,也就是让注册函数在记录函数信息的时候,增加记录对应测试用例的名称。
而这个名称信息,应该记录在哪里呢?有两种代码实现方式:
- 1,另外开辟一个记录测试用例名称的字符串数组;
- 2,修改 test_function_arr 数组中的元素类型,将新增的测试用例名称以及函数地址信息打包成一个数据元素。
显然,相较于第一种实现方式,第二种代码实现方式会使程序具有更好的封装特性。
面就是我们将函数指针信息和测试用例名称信息,封装成的一个新的结构体类型:
1 | struct test_function_info_t { |
如代码所示,我们定义了一种新的数据类型,叫做 test_function_info_t。这种结构体类型包含了指向测试用例的函数指针 func 字段, 与指向测试用例名称的字符串指针 name 字段,并且我们将这种结构体类型,作为 test_function_arr 数组新的元素类型。
既然测试用例信息的存储区 test_function_arr 的数据类型发生了改变,那么负责存储信息的注册函数,与使用信息的 RUN_ALL_TESTS 函数的相关逻辑都需要作出改变。
首先,我们来看注册函数的改变。想要修改注册函数的逻辑,就是修改 TEST 宏,从功能上来说,注册函数中需要额外记录一个测试用例名称信息,示例代码如下:
1 |
代码中主要是增加了第 6 行的逻辑,这一行的代码将 TEST 宏参数的两部分,拼成一个字符串,中间用点 (.) 连接,例如 TEST(test1, test_is_prime) 宏调用中,拼凑的字符串就是 test_is_prime.test1,和 gtest 中的输出的测试用例名称信息格式是一致的。
改完了注册函数的逻辑以后,最后调整一下 RUN_ALL_TESTS 中使用 test_function_arr 数组的逻辑代码即可:
1 | int RUN_ALL_TESTS() { |
至此,我们就完成了输出测试用例名字的框架功能改造。
输出测试用例的运行结果信息
以下是我们示例代码中的 2 个测试用例,在 gtest 框架下的运行结果信息输出:
1 | [ OK ] test_is_prime.test1 (1 ms) |
根据输出的信息,我们可知 gtest 会统计每个测试用例运行的时间,并以毫秒为计量单位,输出此时间信息。不仅如此,gtest 还会输出与测试用例是否正确相关的信息,如果测试用例运行正确,就会输出一行包含 OK 的标志信息,否则就输出一行包含 FAILED 的标志信息。
根据我们自己测试框架的设计,这行信息只有可能是在 RUN_ALL_TESTS 函数的 for 循环中,执行完每一个测试用例函数以后输出的信息。
由此,我们面临的是两个需要解决的问题:
1,如何统计函数过程的运行时间?
函数 clock() 。它的返回值代表了:从运行程序开始,到调用 clock() 函数时,经过的 CPU 时钟计时单元。并且,这个 clock() 函数的返回值,实际上反映的是我们程序的运行时间。那这个 CPU 时钟计时单元究竟是什么呢?你可以把 1 个 CPU 时钟计时单元,简单的理解成是一个单位时间长度,只不过这个单位时间长度,不是我们常说的 1 秒钟。
宏 CLOCKS_PER_SEC 。它实际上是一个整型值,代表多少个 CPU 时钟计时单元是 1 秒。2,如何确定获得每一个测试用例函数的测试结果是否正确?
记录一个全局变量,代表测试用例结果正确与否。当测试用例中的 EXPECT_EQ 断言发生错误时,就修改这个全局变量的值,这样我们的 RUN_ALL_TESTS 函数,就可以在测试用例函数执行结束以后,得知执行过程是否有错。
综合以上所有信息,我们可以重新设计 RUN_ALL_TESTS 函数如下:
1 | int test_run_flag; |
代码中的第 8 行是在测试用例运行之前,记录一个开始时间值 t1;代码中的第 10 行是在测试用例函数执行完后,记录一个结束时间值 t2;在代码的第 17 行,根据 t1 、t2 以及 CLOCKS_PER_SEC 的值,计算得到测试用例函数实际运行的时间,并输出得到的结果。
这段代码中增加了一个全局变量“test_run_flag”,这个变量每次在测试用例执行之前,都会被初始化为 1,当测试用例结束执行以后,RUN_ALL_TESTS 函数中,根据 test_run_flag 变量的值,选择输出 OK 或者 FAILED 的标志信息。同时,我们可以看到,test_run_flag 变量的值只有在 EXPECT_EQ 断言中,才可能被修改。
EXPECT_EQ 断言的实现
首先,EXPECT_EQ(a, b) 在 a,b 两部分值相等的时候,不会产生额外的输出信息,而当 a,b 两部分不相等的时候,就会输出相应的提示信息。如下所示:
1 | gtest_test.cpp:25: Failure |
这段输出信息,对应的是源代码中的 “EXPECT_EQ(is_prime(4), 0); ”的输出。如你所见,第 1 行的输出内容包含了源文件名(gtest_test.cpp),EXPECT_EQ 宏所在的代码位置(25),以及一个提示结果(Failure)。
对于函数调用部分,EXPECE_EQ 会输出这个函数的调用形式及返回值信息,也就是输出中的 “is_prime(4)”“Which is: 1” 这段内容。而对于数值信息,只会输出数值信息本身,也就是输出信息中第 5 行的那个 0。
实际上,要想在宏中实现类似于这种根据传入参数类型,选择输出形式的功能,对于现在的你来说可能有点困难。所以,我们可以重新设计一种输出形式,只要能够清晰地展示错误信息就可以。
重新设计的输出提示,如下所示:
1 | gtest_test.cpp:25: Failure |
修改完以后的输出信息,你可以看到,第 2 行就是传入 EXPECT_EQ 宏两部分的比较,第 3 行是这两部分实际输出值的比较。
重新设计了输出信息以后,就可以来看看 EXPECT_EQ 宏的实现了:
1 |
在这段实现中,你会发现,我们不仅实现了 EXPECT_EQ,还额外实现了 EXPECT_LT、EXPECT_GT、EXPECT_NE 等用于比较的宏。其中,LT 是英文 little 的缩写,是判断小于关系的;GT 是 great 的缩写,是判断大于关系的;NE 是 not equal 的缩写,是判断不等于关系的。而这些所有的宏,都是基于 EXPECT 宏实现的。
小结
工程开发中的一个基本原则:功能迭代,数据先行。也就是说,无论我们做什么样的功能开发,首先要考虑的是与数据相关的部分。
链表知识在测试框架中的应用
用链表存储测试用例
重新审视下面这段函数指针数组 test_function_arr 的代码设计,来思考一下这个测试框架中还有没有可以优化的地方。
1 | struct test_function_info_t { |
这段代码中,我们使用了数组来定义存储测试函数信息的存储区,这个数组的大小有 100 位,也就是说,最多可以存储 100 个测试用例函数信息。
要是有程序中定义了 1000 个测试用例,怎么办呢?
第一步,我们需要改变 test_function_info_t 的结构定义,也就是把原先存储测试用例函数信息的结构体类型,改装成链表结构。最简单的方法,就是在结构体的定义中,增加一个指针字段,指向下一个 test_function_info_t 类型的数据,代码如下所示:
1 | struct test_function_info_t { |
可以看到,我们给 test_function_info_t 结构体类型增加了一个链表中的 next 字段,除此之外,我们还定义了一个虚拟头节点 head 和一个指针变量 tail。这里你需要注意,head 是虚拟头节点,后续我们会向 head 所指向链表中插入链表节点,tail 指针则指向了整个链表的最后一个节点的地址。
第二步,在准备好了数据存储结构以后,需要改写的就是函数注册的逻辑了。在改写 TEST 宏中的注册函数逻辑之前呢,我们先准备一个工具函数 add_test_function,这个工具函数的作用,就是根据传入的参数,新建一个链表节点,并且插入到整个链表的末尾:
1 | void add_test_function(const char *name, test_function_t func) { |
改写 TEST 宏中注册函数的逻辑:
1 |
最后一步,处理完了数据写入的过程以后,来让我们修改一下使用这份数据的代码逻辑,那就是 RUN_ALL_TESTS 函数中的相关逻辑。之前,RUN_ALL_TESTS 函数中,循环遍历数组中的每一个测试用例,并且执行相关的测试用例函数,对这一部分,修改成针对于链表结构的遍历方式即可,代码如下所示:
1 | int RUN_ALL_TESTS() { |
这样,我们就彻底完成了测试用例函数信息存储部分的“链表”改造过程。