# 28 | 尝试升级(上):完善测试框架的功能与提示 你好,我是胡光,欢迎回来。 在上一节课中呢,我们学习了测试框架的主要功能流程,完成了最重要的 RUN\_ALL\_TESTS 函数的功能逻辑。并且在这个学习期间,我们还使用了注册函数的技巧,就是让一些函数先于主函数执行,将测试用例函数信息记录在一个函数指针数组中,为后续的 RUN\_ALL\_TESTS 函数功能的执行作铺垫。 可你有没有发现,我们上节课程所完成的代码,只能让我们的测试框架在整体流程功能上跑通,然而程序的输出内容却不如 gtest 丰富。 今天,我们的主要任务,就是参考 gtest 的输出,逐步完善我们自己测试框架的相关信息输出方面的细节,从而让输出内容更加符合我们想要的信息。来,让我们一起开始吧。 ## 温故知新,gtest 的输出结果 我们先来回顾一下 gtest 的输出结果,gtest 的输出内容大体可以分成三个部分。 第一部分,一套单元测试的相关信息: ``` [==========] Running 2 tests from 1 test suite. [----------] Global test environment set-up. [----------] 2 tests from test_is_prime ``` 这段信息说明这套单元测试中,包含了 2 个测试用例。 第二部分,是每个单元测试运行信息的输出: ``` [ RUN ] test_is_prime.test1 [ OK ] test_is_prime.test1 (1 ms) [ RUN ] test_is_prime.test2 gtest_test.cpp:25: Failure Expected equality of these values: is_prime(4) Which is: 1 0 gtest_test.cpp:26: Failure Expected equality of these values: is_prime(0) Which is: 1 0 gtest_test.cpp:27: Failure Expected equality of these values: is_prime(1) Which is: 1 0 [ FAILED ] test_is_prime.test2 (0 ms) ``` 如上所示,第一个单元测试 test\_is\_prime.test1 运行结果正确,所用时间是 1ms;第二个单元测试 test\_is\_prime.test2 中,有三个判等 EXPECT 断言的结果是错误的,也就是 is\_prime 函数的返回值,和测试用例中期望的返回值不符,这说明 is\_prime 函数存在 Bug。 第三部分,就是这套单元测试的总结信息,以及整个程序单元测试结果的汇总信息。这段信息,有兴趣的小伙伴可以自己理解着看一下,由于不是咱们今天课程的重点,就不展开介绍了。 ``` [----------] 2 tests from test_is_prime (1 ms total) [----------] Global test environment tear-down [==========] 2 tests from 1 test suite ran. (1 ms total) [ PASSED ] 1 test. [ FAILED ] 1 test, listed below: [ FAILED ] test_is_prime.test2 1 FAILED ``` 好了,关于gtest 的输出内容,我大致说清楚了。 今天呢,我们先忽略 gtest 输出内容的第一部分和第三部分,主要关注 gtest 输出内容的第二部分,也就是每个单元测试运行信息的输出部分。通过第二部分的输出内容,你能想出我们应该从哪些方面来完善测试框架? 这里呢,我给出我的想法:通过观察第二部分的输出,我们基本要从三个方面完善测试框架的输出信息。 1. 在每个测试用例运行之前,要先行输出相关测试用例的名字; 2. 每个测试用例运行结束以后,要输出测试用例的运行时间与运行结果(OK 或者 FAILED); 3. 若测试用例中的 EXPECT 断言出错,需要输出错误提示信息。 好了,优化的方向找到了,那么接下来,我们就开始测试框架改装行动吧! ## 测试用例的名字输出 首先是如何输出测试用例的名字。我们先回忆一下上节课设计的注册函数,如下所示: ``` #define TEST(test_name, func_name) \ void test_name##_##func_name(); \ __attribute__((constructor)) \ void register_##test_name##_##func_name() { \ test_function_arr[test_function_cnt] = test_name##_##func_name; \ test_function_cnt++; \ } \ void test_name##_##func_name() ``` 注册函数是随着 TEST 展开的,从展开的代码逻辑中可以看到,它只是将测试用例的函数地址记录在了函数指针数组中。要想 RUN\_ALL\_TESTS 函数后续能够输出测试用例的函数名称的话,我们只需要修改注册函数的功能逻辑即可,也就是让注册函数在记录函数信息的时候,增加记录对应测试用例的名称。 而这个名称信息,应该记录在哪里呢?有两种代码实现方式: 1. 另外开辟一个记录测试用例名称的字符串数组; 2. 修改 test\_function\_arr 数组中的元素类型,将新增的测试用例名称以及函数地址信息打包成一个数据元素。 显然,相较于第一种实现方式,第二种代码实现方式会使程序具有更好的封装特性。我们采用之前在“语言基础篇“中学习的结构体相关知识,就可以完成这种多种数据类型打包成一种新的数据类型的功能需求。 下面就是我们将函数指针信息和测试用例名称信息,封装成的一个新的结构体类型: ``` struct test_function_info_t { test_function_t func; // 测试用例函数指针,指向测试用例函数 const char *name; // 指向测试用例名称 } test_function_arr[100]; int test_function_cnt = 0; ``` 如代码所示,我们定义了一种新的数据类型,叫做 test\_function\_info\_t。这种结构体类型包含了指向测试用例的函数指针 func 字段, 与指向测试用例名称的字符串指针 name 字段,并且我们将这种结构体类型,作为 test\_function\_arr 数组新的元素类型。 既然测试用例信息的存储区 test\_function\_arr 的数据类型发生了改变,那么负责存储信息的注册函数,与使用信息的 RUN\_ALL\_TESTS 函数的相关逻辑都需要作出改变。 首先,我们来看注册函数的改变。想要修改注册函数的逻辑,就是修改 TEST 宏,从功能上来说,注册函数中需要额外记录一个测试用例名称信息,示例代码如下: ``` #define TEST(test_name, func_name) \ void test_name##_##func_name(); \ __attribute__((constructor)) \ void register_##test_name##_##func_name() { \ test_function_arr[test_function_cnt].func = test_name##_##func_name; \ test_function_arr[test_function_cnt].name = #func_name "." #test_name; \ test_function_cnt++; \ } \ void test_name##_##func_name() ``` 代码中主要是增加了第 6 行的逻辑,这一行的代码将 TEST 宏参数的两部分,拼成一个字符串,中间用点 (.) 连接,例如 TEST(test1, test\_is\_prime) 宏调用中,拼凑的字符串就是 test\_is\_prime.test1,和 gtest 中的输出的测试用例名称信息格式是一致的。 改完了注册函数的逻辑以后,最后调整一下 RUN\_ALL\_TESTS 中使用 test\_function\_arr 数组的逻辑代码即可: ``` int RUN_ALL_TESTS() { for (int i = 0; i < test_function_cnt; i++) { printf("[ RUN ] %s\n", test_function_arr[i].name); test_function_arr[i].func(); printf("RUN TEST DONE\n\n"); } return 0; } ``` 代码中的第 3 行,是仿照 gtest 的输出格式进行调整的,在输出测试用例名称之前,先输出一段包含 RUN 英文的标志信息。 至此,我们就完成了输出测试用例名字的框架功能改造。 ## 输出测试用例的运行结果信息 接下来,就让我们进行第二个功能改造:输出测试用例的运行结果信息。 以下是我们示例代码中的 2 个测试用例,在 gtest 框架下的运行结果信息输出: ``` [ OK ] test_is_prime.test1 (1 ms) [ FAILED ] test_is_prime.test2 (0 ms) ``` 根据输出的信息,我们可知 gtest 会统计每个测试用例运行的时间,并以毫秒为计量单位,输出此时间信息。不仅如此,gtest 还会输出与测试用例是否正确相关的信息,如果测试用例运行正确,就会输出一行包含 OK 的标志信息,否则就输出一行包含 FAILED 的标志信息。 根据我们自己测试框架的设计,这行信息只有可能是在 RUN\_ALL\_TESTS 函数的 for 循环中,执行完每一个测试用例函数以后输出的信息。 由此,我们面临的是两个需要解决的问题: 1. 如何统计函数过程的运行时间? 2. 如何确定获得每一个测试用例函数的测试结果是否正确? 说到如何统计函数过程的运行时间,我这里就需要介绍两个新的知识点,一个是函数 clock() ,另 一个是宏 CLOCKS\_PER\_SEC。下面我会对它们详细讲解。 我们先说函数 clock() 。它的返回值代表了:从运行程序开始,到调用 clock() 函数时,经过的 CPU 时钟计时单元。并且,这个 clock() 函数的返回值,实际上反映的是我们程序的运行时间。那这个 CPU 时钟计时单元究竟是什么呢?你可以把 1 个 CPU 时钟计时单元,简单的理解成是一个单位时间长度,只不过这个单位时间长度,不是我们常说的 1 秒钟。 接下来,我们再说说宏 CLOCKS\_PER\_SEC 。它实际上是一个整型值,代表多少个 CPU 时钟计时单元是 1 秒。这个值在不同环境中会有所不同,在早年我的 Windows 电脑上,这个值是1000,也就是1000 个 CPU 时钟计时单位等于 1 秒。而现在我的 Mac 电脑上,这个值是 1000000,也就是 1000000 个 CPU 时钟计时单位等于 1 秒钟。显然,这个数字越大,统计粒度就越精细。 有了上面这两个工具,我们就可以轻松地统计一个函数的运行时间。在函数运行之前,记录一个 clock() 值,函数运行结束以后,再记录一个 clock() 值,用两个记录值的差值除以 CLOCKS\_PER\_SEC ,得到的就是以秒为单位的函数运行时间,再乘以 1000,即为毫秒单位。 这样呢,我们就解决了刚刚提的第一个问题:统计函数过程的运行时间。 至于如何获得每一个测试用例的测试结果,我们可以采用一个简单的解决办法,那就是记录一个全局变量,代表测试用例结果正确与否。当测试用例中的 EXPECT\_EQ 断言发生错误时,就修改这个全局变量的值,这样我们的 RUN\_ALL\_TESTS 函数,就可以在测试用例函数执行结束以后,得知执行过程是否有错。 综合以上所有信息,我们可以重新设计 RUN\_ALL\_TESTS 函数如下: ``` int test_run_flag; #define EXPECT_EQ(a, b) test_run_flag &= ((a) == (b)) int RUN_ALL_TESTS() { for (int i = 0; i < test_function_cnt; i++) { printf("[ RUN ] %s\n", test_function_arr[i].name); test_run_flag = 1; long long t1 = clock(); test_function_arr[i].func(); long long t2 = clock(); if (test_run_flag) { printf("[ OK ] "); } else { printf("[ FAILED ] "); } printf("%s", test_function_arr[i].name); printf(" (%.0lf ms)\n\n", 1.0 * (t2 - t1) / CLOCKS_PER_SEC * 1000); } return 0; } ``` 代码中的第 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 断言的实现,这个就给你留作思考题,请你基于我上述所讲的内容,试试自己实现这个带错误提示输出的 EXPECT\_EQ 断言吧。也欢迎你把你的答案写在留言区,我们一起讨论。 ## 课程小结 通过今天的课程呢,我希望你认识到**工程开发中的一个基本原则:功能迭代,数据先行。也就是说,无论我们做什么样的功能开发,首先要考虑的是与数据相关的部分。**更细致的解释,就是你考虑某种功能的实现,要明白这个功能都依赖于哪些数据信息,这些信息在哪里存储,在哪里修改,在哪里读取使用。把数据相关部分设计明白了,你的功能开发也就基本实现了一半了。 就像我们今天改造的第一个功能,输出测试用例的名字。 首先,我们考虑如何存储名字信息,最先被修改的就是 test\_function\_arr 数组的数据类型,我们改造了数据存储的结构。然后,我们修改了注册函数的相关功能逻辑,也就是解决了数据的写入与修改过程。最后,我们修改 RUN\_ALL\_TESTS 中的输出逻辑,也就是解决了数据在哪里读取和使用的事情。 至此,我已经向你演示了基本的功能迭代开发过程。接下来你可以自己试着,给输出的内容加上点儿颜色,以便更清晰地展示测试过程中的测试信息。除此之外呢,你也可以开动你的创造力,给测试框架加些令人惊喜的功能。 好了,今天就到这里了,我是胡光,我们下节课见。