27 | 牛刀小试(下):实现一个自己的测试框架

上节课中,我们讲到了软件开发一般分为前中后三个部分,提到作为技术人员的我们,一般主要负责在软件开发中期的编码与测试阶段。还有,我还讲到我们一般会综合运用白盒测试与黑盒测试这两种方法来进行程序测试。

更主要的是,我们还介绍了 Google 的单元测试框架 gtest,并对测试框架代码进行了一番解读。其中提到代码中的 TEST 是一个宏,那它展开后被替换的内容是什么呢?还有, RUN_ALL_TESTS 函数是如何依次执行程序中所有的测试用例函数的?

今天呢,我们就一个一个地来解决这些问题,并最终实现一个咱们自己的测试框架。

初步实现 TEST 宏

今天我们实现的所有代码呢,都会写在一个名字为 geek_test.h 的头文件中。当然我们也知道,将声明和定义写在一起,在大型工程中是会出现严重的编译错误,在实际的工程开发中,我们并不会这么做。

今天把声明和定义写在一起,只是为了课程内容的讲解需要,而你也完全没有必要担心,这不会影响你对主要内容的学习。

我们先回到上节课中的源代码:

#include <stdio.h>
#include "geek_test.h" // 替换掉原 gtest/gtest.h 头文件

// 判断一个数字 x 是否是素数
int is_prime(int x) {
    for (int i = 2; i * i < x; i++) {
        if (x % i == 0) return 0;
    }
    return 1;
}

// 第一个测试用例
TEST(test1, test_is_prime) {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

// 第二个测试用例
TEST(test2, test_is_prime) {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

int main() {
    return RUN_ALL_TESTS();
}

我们的目的,是在不改变这份源代码的前提下,通过在 geek_test.h 中添加一些源码,使得这份代码的运行效果,能够类似于 gtest 的运行效果。

想要完成这个目标,我们就要先来思考 TEST 宏这里的内容,请你仔细观察这段由 TEST 宏定义的测试用例的相关代码:

TEST(test1, test_is_prime) {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

TEST(test1, test_is_prime) 这部分应该是在调用 TEST 宏,而这部分被预处理器展开以后的内容,只有和后面大括号里的代码组合在一起,才是一段合法的 C 语言代码,也只有这样,这份代码才能通过编译。

既然如此,我们就不难想到,TEST 宏展开以后,它应该是一个函数定义的头部,后面大括号里的代码,就是这个展开以后的函数头部的函数体部分,这样一切就都说得通了。

在实现 TEST 宏之前,我们还需要想清楚一个问题:由于程序中可以定义多个 TEST 测试用例,如果每一个 TEST 宏展开都是一个函数头部的话,那这个展开的函数的名字是什么呢?如果每一个 TEST 宏展开的函数名字都一样,那程序一定无法通过编译,编译器会报与函数名重复相关的错误,所以, TEST 宏是如何确定展开函数的名字呢?

不知道你有没有注意到,TEST 宏需要传入两个参数,这两个参数在输出信息中与测试用例的名字有关。那我们就该想到,可以使用这两个参数拼接出一个函数名,只要 TEST 传入的这两个参数不一样,那扩展出来的函数名就不同。最后,我们就可以初步得到如下的 TEST 宏的一个实现:

#define TEST(test_name, func_name) \
void test_name##_##func_name()

如代码所示的 TEST 宏实现,我们将 TEST 宏的两个参数内容使用 ## 连接在一起,中间用一个额外的下划线连接,组成一个函数名字,这个函数的返回值类型是 void,无传入参数。

根据这个实现,预处理器会将源代码中两处 TEST 宏的内容,替换成如下代码所示内容:

void test1_test_is_prime() {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

void test2_test_is_prime() {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

这样,也就把原来看似不合理的 TEST 宏,转换成了合法的 C 语言代码了。

__attribute__:让其它函数先于主函数执行

在继续讲测试框架的设计之前,我们来补充一个知识点。

之前,我们所学习到的程序执行过程,既是从主函数开始,也是从主函数结束。也就是说,在常规的程序执行过程中,其它函数都是在主函数执行之后,才被直接或者间接调用执行。接下来,就要给你讲一种能够让函数在主函数执行之前就执行的编程技巧。

首先,我们先来看如下代码:

#include <stdio.h>

void pre_output() {
    printf("hello geek!\n");
    return ;
}

int main() {
    printf("hello main!");
    return 0;
}

代码运行以后,会输出一行字符串 “hello main!”。

接下来呢,我们对上述代码稍微修改,在 pre_output 函数前面加上__attribute__((constructor)) 。这样,pre_output 函数就会先于主函数执行,代码如下:

#include <stdio.h>

__attribute__((constructor))
void pre_output() {
    printf("hello geek!\n");
    return ;
}

int main() {
    printf("hello main!\n");
    return 0;
}

如上代码执行以后,程序会输出两行内容,第 1 行是 pre_output 函数输出的内容 “hello geek!”,第 2 行才是主函数的执行输出内容 “hello main!”。

从输出内容可以看出,加了__attribute__((constructor)) 以后,pre_output 函数会先于 main 主函数执行,这种有趣的特性,在接下来的操作中我们还会用得上,你要理解并记住。

其实 __attribute__ 的作用还很多,你可以上网搜搜,会让你的程序性质变得特别有意思。

RUN_ALL_TESTS 函数设计

好了,准备工作都做完了,接下来让我们来思考一下 RUN_ALL_TESTS 函数要完成的事情,以及完成这些事情所需要的条件。

从主函数中调用 RUN_ALL_TESTS 函数的方式来看,RUN_ALL_TESTS 函数应该是一个返回值为整型的函数。这样,我们可以得到这样的函数声明形式:

int RUN_ALL_TESTS();

从测试框架的执行输出结果中看,RUN_ALL_TESTS 函数可以依次性地执行每一个 TEST 宏扩展出来的测试用例函数,这是怎么做到的呢?

我们可以这样认为:在主函数执行 RUN_ALL_TESTS 函数之前,有一些函数过程,就已经把测试用例函数的相关信息,记录在了一个 RUN_ALL_TESTS 函数可以访问到的地方,等到 RUN_ALL_TESTS 函数执行的时候,就可以根据这些记录的信息,依次性地执行这些测试用例函数。整个过程,如下图所示:

图1: RUN_ALL_TESTS 执行流程

图中红色部分,就是我们推测的,某些完成测试用例函数信息注册的函数,它们先于主函数执行,将测试用例的信息,写入到一个公共存储区中。

接下来,我们需要考虑的就是这些注册函数,究竟将什么信息存储到了公共存储区中,才能使得 RUN_ALL_TESTS 函数可以调用到这些测试用例?你自己也可以想想是什么。答案就是这个信息是测试用例函数的函数地址,因为只有把函数地址存储到这个存储区中,才能保证 RUN_ALL_TESTS 函数可以调用它们。所以,这片公共存储区,就应该是一个函数指针数组。

那如何解决注册函数问题呢?最简单直接的设计方法,就是每多一个由 TEST 宏定义的测试用例,就配套一个注册函数,所以这个注册函数的逻辑,可以设计在 TEST 宏展开的内容中。这就需要我们对 TEST 宏进行重新设计,这里一会儿再给你进行说明。

我们先来完成 RUN_ALL_TESTS 函数从存储区中,读取并执行测试用例的过程: 

typedef void (*test_function_t)();

test_function_t test_function_arr[100];
int test_function_cnt = 0;

int RUN_ALL_TESTS() {
    for (int i = 0; i < test_function_cnt; i++) {
        printf("RUN TEST : %d\n", i + 1);
        test_function_arr[i]();
        printf("RUN TEST DONE\n\n");
    }
    return 0;
}

代码中用到了函数指针相关的技巧,其中 test_function_t 是我们定义的函数指针类型,这种函数指针类型的变量,可以用来指向返回值是 void,传入参数为空的函数。

之后,定义了一个有 100 位的函数指针数组 test_function_arr,数组中的每个位置,都可以存储一个函数地址,数组中元素数量,记录在整型变量 test_function_cnt 中。这样,RUN_ALL_TESTS 函数中的逻辑就很简单了,就是依次遍历函数指针数组中的每个函数,然后依次执行这些函数,这些函数每一个都是一个测试用例。

重新设计:TEST 宏

根据前面的分析,TEST 扩展出来的内容,不仅要有测试用例的函数头部,还需要有先于主函数执行的注册函数,主要用于注册 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] = test_name##_##func_name; \
    test_function_cnt++; \
} \
void test_name##_##func_name()

这个新设计的 TEST 宏,除了末尾保留了原 TEST 宏内容以外,在扩展的测试用例函数头部添加了一段扩展内容,这段新添加的扩展内容,会扩展出来一个函数声明,以及一个以 register 开头的会在主函数执行之前执行的注册函数;注册函数内部的逻辑很简单,就是将测试函数的函数地址,存储在函数指针数组 test_function_arr 中,这部分区域中的数据,后续会被 RUN_ALL_TESTS 函数使用。

如果以如上 TEST 宏作为实现,原程序中的两个测试用例代码,会被展开成如下样子:

void test1_test_is_prime();

__attribute__((constructor))
void register_test1_test_is_prime() {
    test_function_arr[test_function_cnt] = test1_test_is_prime; 
    test_function_cnt++;
}

void test1_test_is_prime() {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

void test2_test_is_prime();

__attribute__((constructor))
void register_test2_test_is_prime() { 
    test_function_arr[test_function_cnt] = test2_test_is_prime; 
    test_function_cnt++;
}

void test2_test_is_prime() {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

这个展开内容,是我给你做完代码格式整理以后的样子,实际展开结果会比这个格式乱一点儿,不过代码逻辑都一样。从展开内容中你可以看到,在展开代码的第 4 行和第 18 行分别就是两个测试用例函数的注册函数。

至此,我们就算是初步完成了测试框架中关键的两个部分的设计:一个是 TEST 宏,另外一个就是 RUN_ALL_TESTS 函数。它们同时也是串起测试框架流程最重要的两部分。

关于 EXPECT_EQ 是如何实现的,我就留作思考题吧,也希望你认真想一想,把你的答案写在留言区中,我们一起讨论。这个实现答案肯定不唯一,你只需要尽量做到最好即可。

课程小结

最后,做一下今天的课程小结:

1.__attribute__((constructor)) 可以修饰函数,使修饰的函数先于主函数执行。

2. RUN_ALL_TESTS 之所以可以获得程序中所有测试用例的函数信息,是因为有一批注册函数,将测试用例函数记录下来了。

3. 通过测试框架这个项目,我们再一次看到,宏可以将原本看似不合理的代码,变得合理。

通过这两次课程,我希望你意识到,我们不是在阅读已有的测试框架的源码,而是在根据已有的测试框架,脑补其内部实现过程。

其实,脑补这个能力,往往是项目开发中的重要能力之一。例如,根据产品需要的外在功能描述,脑补后续的开发细节;根据竞品可见的功能表现,脑补其背后的技术细节。能够脑补一个产品的实现细节,可以让我们逐渐掌握,认清相关技术边界的能力,这个能力可以让我们不盲目崇拜某个公司,也不会随意轻视某个产品。



本文介绍了如何实现一个自己的测试框架,通过讲解 TEST 宏的实现、`__attribute__`的使用和 RUN_ALL_TESTS 函数的设计,帮助读者了解了测试框架的基本原理和实现方法。作者首先讲解了如何初步实现 TEST 宏,通过预处理器展开,将 TEST 宏的两个参数内容连接在一起,形成一个函数名,从而将原来的 TEST 宏转换成合法的 C 语言代码。接着,作者介绍了`__attribute__`的使用,通过`__attribute__((constructor))`可以让函数在主函数执行之前就执行,这为后续的操作提供了便利。然后,文章讲解了 RUN_ALL_TESTS 函数的设计,通过注册函数将测试用例函数的函数地址存储到一个函数指针数组中,然后在 RUN_ALL_TESTS 函数中依次执行这些函数,实现了测试用例的执行。文章还提到了`__attribute__`((constructor)) 可以修饰函数,使修饰的函数先于主函数执行,以及通过测试框架这个项目,宏可以将原本看似不合理的代码变得合理。通过这两次课程,读者可以意识到,不是在阅读已有的测试框架的源码,而是在根据已有的测试框架,脑补其内部实现过程。这篇文章对于想要了解测试框架的基本原理和实现方法的读者来说是一篇很有价值的文章。 

  • 29
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值