不会飞的章鱼

熟能生巧,勤能补拙;念念不忘,必有回响。

编程入门项目一点五:升级自己的测试框架

完善测试框架的功能与提示

温故知新,gtest 的输出结果

第一部分,一套单元测试的相关信息:

1
2
3
[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from test_is_prime

第二部分,是每个单元测试运行信息的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[ 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。

第三部分,就是这套单元测试的总结信息,以及整个程序单元测试结果的汇总信息。这段信息,有兴趣的小伙伴可以自己理解着看一下,由于不是咱们今天课程的重点,就不展开介绍了。

1
2
3
4
5
6
7
8
9
[----------] 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

从哪些方面来完善测试框架?

通过观察第二部分的输出,我们基本要从三个方面完善测试框架的输出信息。

  • 在每个测试用例运行之前,要先行输出相关测试用例的名字;
  • 每个测试用例运行结束以后,要输出测试用例的运行时间与运行结果(OK 或者 FAILED);
  • 若测试用例中的 EXPECT 断言出错,需要输出错误提示信息。

测试用例的名字输出

首先是如何输出测试用例的名字。我们先回忆一下上节课设计的注册函数,如下所示:

1
2
3
4
5
6
7
8
#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 数组中的元素类型,将新增的测试用例名称以及函数地址信息打包成一个数据元素。

显然,相较于第一种实现方式,第二种代码实现方式会使程序具有更好的封装特性。

面就是我们将函数指针信息和测试用例名称信息,封装成的一个新的结构体类型:

1
2
3
4
5
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 宏,从功能上来说,注册函数中需要额外记录一个测试用例名称信息,示例代码如下:

1
2
3
4
5
6
7
8
9
#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 数组的逻辑代码即可:

1
2
3
4
5
6
7
8
int RUN_ALL_TESTS() {
for (int i = 0; i < test_function_cnt; i++) {
printf("[ RUN ] %s\n", test_function_arr[i].name); //仿照 gtest 的输出格式进行调整的,在输出测试用例名称之前,先输出一段包含 RUN 英文的标志信息。
test_function_arr[i].func();
printf("RUN TEST DONE\n\n");
}
return 0;
}

至此,我们就完成了输出测试用例名字的框架功能改造。

输出测试用例的运行结果信息

以下是我们示例代码中的 2 个测试用例,在 gtest 框架下的运行结果信息输出:

1
2
[     OK ] test_is_prime.test1 (1 ms)
[ FAILED ] test_is_prime.test2 (0 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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(a, b) 在 a,b 两部分值相等的时候,不会产生额外的输出信息,而当 a,b 两部分不相等的时候,就会输出相应的提示信息。如下所示:

1
2
3
4
5
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)。

对于函数调用部分,EXPECE_EQ 会输出这个函数的调用形式及返回值信息,也就是输出中的 “is_prime(4)”“Which is: 1” 这段内容。而对于数值信息,只会输出数值信息本身,也就是输出信息中第 5 行的那个 0。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
#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 宏实现的。

小结

工程开发中的一个基本原则:功能迭代,数据先行。也就是说,无论我们做什么样的功能开发,首先要考虑的是与数据相关的部分。

链表知识在测试框架中的应用

用链表存储测试用例

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

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

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

要是有程序中定义了 1000 个测试用例,怎么办呢?

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

1
2
3
4
5
6
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,这个工具函数的作用,就是根据传入的参数,新建一个链表节点,并且插入到整个链表的末尾:

1
2
3
4
5
6
7
8
9
10
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 ;
}

改写 TEST 宏中注册函数的逻辑:

1
2
3
4
5
6
7
8
#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 函数中,循环遍历数组中的每一个测试用例,并且执行相关的测试用例函数,对这一部分,修改成针对于链表结构的遍历方式即可,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}

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

------ 本文结束------
如果本篇文章对你有帮助,可以给作者加个鸡腿~(*^__^*),感谢鼓励与支持!