单元测试框架gtest使用研究


【简介】

gtest是一款由google开发并开源的C++单元测试框架,对C++的各种单元测试场景提供了完备的支持,并且可以在多种平台下运行,本文主要介绍gtest框架中测试部分的内容(mock部分将在下一篇中介绍)。gtest在Github地址为:GitHub - google/googletest: Googletest - Google Testing and Mocking Framework  

下载并解压后,gtest的目录结构如下图所示:

gtest目录结构

gtest提供了两种编译方式bazel和cmake,本文时使用cmake进行编译:

mkdir build                                                                                                                                          cd build                                                                                                                                              cmake ../                                                                                                                                              make                                                                                                                                                

编译完成后会在文件夹build/lib中生成目标库文件:

编译得到的库文件

在向工程中集成gtest框架时需要引入上述编译好的库和头文件,其中libgtest*.a是gtest相关的库,libgmock*.a系列是gmock相关的库。它们对应的头文件位于代码根目录下googletest/include与googlemock/include文件夹。编写测试代码的时候,只需要包含头文件 gtest.h或者gmock.h即可。



为了方便用户使用,gtest框架中大量的测试代码都是以宏调用的形式完成,在开始测试之前先介绍一个最基本的宏:

                                       TEST(testsuiteName,testcaseName)

TEST宏是gtest最基本的组成单元,在编写测试代码时,会大量使用到该宏。TEST接收两个参数,第一个参数为testsuite(测试套件),第二个参数为testcase(测试用例)。概念上每一个testsuite代表了一组完成某个特定测试任务的testcase的集合,每一个testsuite可以由一个或者多个testcase组成。testcase代表了一个具体的测试动作,在TEST宏内部就是这个testcase具体测试的业务的代码实现:

TEST(AddFunctionTest,PositiveAdd)
{
           ASSERT_EQ(add(1,2),3);

这个测试用例PositiveAdd属于测试套件AddFunctionTest,内部ASSERT_EQ*部分的意思是断言函数add(1,2)执行返回的结果与3相等。具体的测试用例编写方式将在后文详细描述。由于gtest的内部实现中会对testsuite和testname的名字进行改编,因此原则上在命名时不允许有下划线出现(如果你加了下划线也不会报错,但是会有隐患)。

【断言】

断言是gtest核心组成,所有的测试用例最终都是以断言实现的,因此gtest对断言提供了丰富的宏支持。整体上gtest的断言主要分为ASSERT*与EXPECT*系列,ASSERT*系列的断言如果失败,则会产生一个严重错误并导致当前作用范围的测试用例中断(请注意“当前”,gtest中错误是无法跨作用域传递的,如果需要传递则需要一些特殊的处理,这部分在后边会介绍),相对的EXPECT*不会产生任何错误,测试用例依旧可以继续执行。EXPECT*可以让用户在一个测试用例中检查多次输入返回,即使失败也不退出;ASSERT*可以在测试用例中产生严重错误导致后续操作无意义时直接中断测试。这两类断言在语法支持上都是一一对应的,可以根据具体的需求使用。


两个数比较断言

上图中给出了两个数比较的断言说明(摘自gtest说明文档primer.md),可以看到对于同一个断言都有对应的ASSERT与EXPECT版本,出了数字比较还有布尔类型、字符串等类型的比较断言,具体的定义请读者自行在gest.h中查找。

断言扩展:

除了通过之前介绍的ASSERT*与EXPECT*系列断言支持各种类型的内容检查,gtest还提供了一些额外的扩展断言宏:
SUCCEED():产生一个成功标识,不会让测试用例直接成功,只代表某一个步骤成功
FAIL():产生一个严重错误,相当于一个ASSERT*宏失败
ASSERT_THROW(expre,type) :断言表达式expre会抛出一个type类型的异常
ASSERT_ANY_THROW(expre):断言表达式expre会抛出一个任意类型的异常
ASSERT_NO_THROW(expre): 断言表达式expre不会抛出任何异常


【TESTFIXTURE】

在编写单元测试用例的过程中,我们会遇到这样的场景:一个测试套件由多个测试用例构成,每个测试用例在执行前需要编写一些额外的代码进行数据初始化,而且在测试用例生成过程中,这部分数据可能会发生改变,无法复用,从而产生了很多重复性的工作,因此gtest中引入了Testfixture。本质上就是将上述资源管理动作封装到一个类中,在测试用例执行前完成初始化,在执行后进行资源释放。既然gtest提供的是单元测试框架,自然已经提供了完备的支持,我们的接入方式也非常容易:

1.创建一个类公有继承自::testing::Test。
2.类内部已protected进行声明。
3.实现构造/析构或者SetUp/TearDown函数。
4.完善类的资源信息。
5.使用TEST_F宏替代TEST,第一个参数testsuite填写新建类的名称。

TEST_F宏声明测试用例后,会在测试用例代码执行之前创建一个只属于该测试用例的TestFixture类对象,在测试用例执行结束后,会析构该对象。步骤3中提到的两对函数:
                                       
                                        构造/析构函数
                                        SetUp/TearDown

它们都提供了资源的创建与释放,它们的调用顺序为:构造 =》SetUp =》TearDown =》析构。理论上只需要实现其中一对即可,但是由于C++语法限制与安全性考虑,这两对函数有不同的应用场景:

使用析构/构造函数
建议优先使用构造/析构函数完成数据的初始化,这样即使有别的类继承自该TestFixture类,由于构造/析构链,可以保证所有的资源都被正确地释放,同时在类的构造函数中我们可以对类的成员进行const修饰,这样保证了数据的安全性。

使用SetUp/TearDown
由于C++语法不允许在构造/析构函数中调用虚函数;gtest的ASSERT*系列宏也无法在构造/析构函数中实现;析构函数中无法抛出异常,如果资源释放过程可能有异常产生也只能放在TearDown中。

上述描述都是测试用例之间不可共享数据的例子,在这个基础上如果产生了部分需要在测试用例之间共享的数据需求,按照C++语法的特性,只需要引入static类型的变量即可,对应的::testing::Test中也实现了静态函数对SetUpTestSuite/TearDownTestSuite。从名字就可以看出TestFixture本身也可以看做是一种特殊的测试套件。

整体上讲SetUp/TearDown属于测试用例内部的资源共享,SetUpTestSuite/TearDownTestSuite属于测试套件内部的资源共享,全局的资源共享方式也是存在的,但是它已经超越了TestFixture的范畴,因此不在本小节介绍。


【DeathTest】

程序中会有一些导致程序崩溃的异常分支,这也是单元测试需要覆盖的部分之一,因此gtest提供了DEATHTEST系列宏提供程序崩溃的场景模拟,但是需要指出的是,下面介绍的DEATHTEST相关宏会创建一个单独进程来模拟程序崩溃的过程,并不是直接在当前进程完成的。常用的DEATHTEST宏如下:
ASSERT_DEATH(statement, matcher) :statement导致程序按照设定的错误崩溃
ASSERT_EXIT(statement, predicate, matcher):statement导致程序按照设定的错误退出,退出返回码与predicate一致

其中参数matcher可以是一个用于匹配字符串的gmock matcher或者是一个正则表达式,他们通过匹配程序崩溃或者退出时stderr输出的错误信息,因此如果确实需要一个matcher,请确保matcher能正确匹配到期望的内容,如果不需要匹配器直接填空字符串即可。

一个ASSERT_EXIT的例子:


DEATHTEST例子

ASSERT_DEATH比较简单,只需要构建一个令程序崩溃的语句即可,在上述的ASSERT_EXIT例子中,通过SigRaise向程序发送了一个可以导致程序退出的信号SIGKILL(9),predicate参数为由testing提供的方法KilledBySignal,matcher直接置空,不进行任何匹配。

如果对于上述例子进行修改:


退出方法

带matcher的DEATHTEST

与之前的例子相比,这次试用了自定义的程序退出方法ExitNum,在程序退出的时候向stderr写入了信息,在测试用例 ExitWith0中ASSERET_EXIT第三个参数填入了正则表达式,显然无法完成匹配,因此执行后会失败,如果将正则表达式修改为"exit*",执行就会发现测试通过。

需要注意的是DEATHTEST监听的是程序崩溃或者退出的状态,如果一个statement只是导致了程序抛出了一个异常,DEATHTEST的监听不会触发。DEATHTEST有两种模式fast与threadsafe,fast模式在子进程中只执行“死亡表达式”而threadsafe模式则会执行完成的代码,直到“死亡表达式”。显示全局变量testing::FLAGS_gtest_death_test_style可以完成模式的切换(必须在测试用例执行前调用)。

【完善测试错误信息】

有些时候单元测试的目标方法会比较复杂,中间会有很多分支,返回类型也不一定是布尔类型,按照之前介绍的ASSERT/EXPECT机制,如果只是简单的给出结果为TRUE或者FALSE,显然不能准确地反映出真实的测试情况。单元测试的目的是发现问题并解决问题,因此单元测试需要明确的告知用户错误产生的位置和原因。另外,有时候用户需要测试一个复杂的表达式,而gtest提供的断言往往是2元的,所以用户为了进行测试就需要编写复杂的测试代码,如果出错进一步增加了获取错误原因的难度。既然gtest不能以穷举的方法满足用户,只能提供了模板化的方式让用户完成自定义表达式与提示信息。

针对于不同的应用场景gtest提供了不同的支持机制:

方法1:  Using an Existing Boolean Function

这种情况比较简单,因为函数本身就只有TRUE与FALSE两种值,因此我们只需有一个支持变参的断言表达式即可。gtest提供了*_PRED*(pred,val1,...)系列断言,表达式中第一个*代表 ASSERT或者EXPECT,第二个*是一个数字,代表了被测试表达式(函数)的参数数量。下边给出一个接收三个参数返回布尔类型的函数的测试:


三参数函数的断言测试

方法2:Using a Function That Returns an AssertionResult

和方法1相比,这是一种普遍情况,我们可以用一种方式将各种返回值规整为可以使用gtest断言处理的结果。gtest提供了一个类类型testing::AssertionResult,其中有两个关键的方法:

                                          testing::AssertionSuccess()
                                          testing::AssertionFailure()

我们可以通过对函数的分支进行分别处理进而使其变成一个可"断言"的表达式:


testing::AssertionResult应用

其中TriBoolFunc是一个接收两个int类型的数字,如果相等返回0,a大于b返回大于0,a小于b返回小于0。在TriBoolFuncAssert中我们对返回结果进行了改变,如果传入的值a

通过创建一个返回testing::AssertionResult的函数来封装待测试函数,相当于是用另外一种方式使得所有的函数的各种返回类型(包括return返回、引用带出返回值等),均可以转化为一个可以使用断言测试的结果。这是一种非常有效的方式,但是因为在断言测试时显示调用的是另外一个方法,当测试代码达到一定规模时,同样的一个EXPECT_TRUE(func(a))我们无法直接从字面看出到底func是原始代码还是经过AssertionResult封装的方法。因此gtest又引入了方法3的处理。

方法3:Using Predicate-Formatter

Predicate-Formatter就是用来解决方法2中的问题,并给出了一个标准化的解决方案:

                       ASSERT_PRED_FORMAT2(pred_format2, val1, val2)
                       EXPECT_PRED_FORMAT2(pred_format2, val1, val2)

这个断言格式与情况1中*_PRED*类似,FORMAT后跟的数字代表了实际参数的数量。pred_format也是一个返回testing::AssertionResult的函数,但是这个函数有一个标准的表达式:


pred_format函数范式

其中exprn代表第n个参数的字符串表达式,Tn valn代表函数实际执行业务需求的类型为Tn的参数。函数本身的实现方式与方法2中介绍的类似:


Predicate-Formatter Demo

本质上除了参数以外,其内部实现与情况2中介绍的函数一致,唯一区别是使用断言使用的是专用的断言宏:


Predicate-Formatter断言使用

本质上情况1中介绍的*_PRED*也属于Predicate-Formatter,参考EXPECT_PRED1的实现,在内部只是自己实现了一个EXPECT_PRED_FORMAT要求的pred_format函数:


EXPECT_PRED1的pred_format函数

总结一下方法1属于方法3的进一步封装,由框架内部实现了错误信息的输出。方法2给出了方法3的实现方式(此类问题普遍的实现方式):本质上都是将一个任意方法封装成一个返回AssertionResult的函数。同时需要注意的是标准版本的gtest只提供了参数数量最多为5的宏,如果需要更多的参数需要自行实现或者联系gtest官方。但是如果一个方法接收过多的参数,首先应该考虑对这个方法进行重构。

【访问类的私有成员】

原则上讲在类外部访问一个类的私有成员这个需求是不合理的,既然将一个方法声明为私有,则应该通过类的共有成员对其进行访问和验证。因此,如果遇到这样的需求,首先应该研究是否可以通过调整代码的结构来避免这种问题。如果无法解决,只能通过宏FRIEND_TEST来“破坏”类的封闭性(本质上是通过友元实现),但是不幸的是,这个宏需要写入到源代码中,导致测试代码和源代码纠缠在了一起。


在一个类中声明FRIEND_TEST


如上图代码所示,类PrivateClass中调用了两次FRIEND_TEST宏,在声明后,测试套件PrivateClassSuite和PrivateClassFixture对应的TESTCASE均可以访问类的私有方法add。如果目标测试使用TEST宏,则可以直接调用,但是TESTSUITE与TESTCASE名称必须与FRIEND_TEST中声明一致。


TEST宏访问类私有变量

如果要使用TEST_F宏则需要创建一个TESTFIXTURE,名字必须与FRIEND_TEST中声明的一致。


TEST_F与FRIEND_TEST联合使用

需要注意的是创建TEST或者TEST_F时TESTSUITE与TESTCASE名字必须与之前在FRIEND_TEST中的声明完全一致,否则会有编译错误。

Value-Parameterized Tests


当我们在编写测试用例的时候,需要向某个方法传入不同的参数进行验证,按照之前介绍的方法我们需要编写大量的重复代码。如果可以将传入的值参数化,我们每次调用只需要修改传入内容即可。这就是gtest提供的Value-Parameterized技术。

首先需要创建一个TestFixture,除了继承自testing::Test以外还需要继承自:
                                  testing::WithParamInterface   
为了简化类定义,可以只继承自类:
                                   testing::TestWithParam
其中T就是模板化的才参数,需要在创建TextFixture时就指明,这就是可以多次传入参数的类型。


Value-Parameterized类定义

需要注意的是,这个类仍然是一个TestFixture,虽然它隐式继承了testing::Test,如果需要为其添加SetUp/TearDown,需要将其声明为public而不是protected,否则其会因为访问权限而无法正常工作。
然后引入一个新的宏TEST_P,与TEST和TEST_F不同的是,这个宏相当于创建了一个测试模板,需要使用INSTANTIATE_TEST_SUITE_P宏完成参数的传递并创建真正的测试用例。


Value-Parameterized具体实现

其中INSTANTIATE_TEST_SUITE_P第一个参数相当于一个批量值测试的名字,可以自定义,第二个参数为之前定义的TestFixture名称,第三个参数为传递的参数列表。在这个例子相当于调用了三次EXPECT_EQ(keys,GetParam()),传入的keys值为1,2,3。INSTANTIATE_TEST_SUITE_P宏只能声明在全局或者命名空间范围,不能将其放在某个方法内部,否则无法编译通过。

【Type Test】
当一个函数有多个版本的实现,接收不同类型的参数,如果使用之前介绍的Value-Parameterized技术,则需要根据每个实现版本创建一个TestFixture。为了减少重复代码,我们可以把TestFixture模板化:


模板化TestFixture

创建好模板类后,我们使用宏TYPED_TEST_SUITE将模板类实例化:


TestFixture实例化

然后使用TYPED_TEST代替TEST_F完成测试用例的编写:


TYPED_TEST使用

这个例子中由于在实例化TestFixture时,value_的类型会被设置为int和unsigned int,将其值设置为-1,unsigned int将会产生一个巨大的正整数,这样机会产生不同的测试结果。TYPED_TEST会按照TYPED_TEST_SUITE实例化的TestFixture类时传入的类型,逐个构建测试用例并执行。

【Type-Parameterized Tests】
结合Type Test中的介绍,自然有方法可以类比Value-Parameterized实现Type-Parameterized技术:


Type-Parameterized

首先使用宏TYPED_TEST_SUITE_P创建一个Type-Parameterized的测试套件。
然后使用宏TYPED_TEST_P构建一个测试用例模板,主要完成测试用例具体测试步骤。
然后使用用REGISTER_TYPED_TEST_SUITE_P完成注册,后续实例化时就会从已经注册的Type-Parameterized测试用例列表中逐个处理。
最后使用宏INSTANTIATE_TYPED_TEST_SUITE_P,传入TestSuite名(参数2)和实例化类型(参数3)完成测试用例的实例化。

整体流程与Value-Parameterized类似,都是遵循创建TestFixture模板,创建测试用例模板,传递参数完成测试用例的实例化。核心思想都是用模板化技术来减少重复代码的数量。

全局共享资源
之前在TestFixture中介绍了可以在Testcase与TestSuite级别共享资源的方式,gtest同样提供了全局资源共享的模式,只不过使用方式稍有区别。这里需要创建一个环境类继承自testing::Environment然后再其内部实现SetUp/TearDown方法(构造和析构也是可以的),最后在测试main方法中进行加载:


自定义的环境对象

可以根据具体的需求在自定义的环境类中实现需要在全局共享的资源。在main方法需要进行的处理:


main方法记在环境变量

testing::UnitTest是一个单例类,再其内部会有一个vector存储了所有Environment对象,在执行RUN_ALL_TESTS时候会先执行环境初始化,然后执行测试用例,最后再清理环境。测试程序退出时会将所有的environment对象删除。


SetUp Environment

TearDown Environment

Destructor

创建监听器

gtest提供了测试中各种事件的回调方法,我们只需要向UnitTest对象实例注册一个listener并实现listener中所关注的事件回调方法即可。实现方式与Environment类似:


监听测试程序开始与结束的监听器

testing::EmptyTestEventListener提供了丰富的事件监听回调方法,该类声明位于gtest.h中,可以根据需求去查阅。

单元测试的启动
结合之前的例子知道,任意一个单元测试程序,必须按照顺序执行:
                                      testing::InitGoogleTest(&argc,argv);
                                      RUN_ALL_TESTS()
才能完成测试环境的初始化并执行所有测试。当单元测试用例达到一定规模,每个单独的测试程序可能会有大量的测试用例,而在开发时我们可能会有各种各样的执行方式的需求:只执行某一部分、屏蔽某一部分测试用例、循环执行某一部分测试用例等。gtest提供了丰富的执行参数支持,现在介绍一些常用的选项:
--gtest_list_tests   不执行test只是列出程序中包含的测试用例
--gtest_filter    根据过滤条件执行某些测试用例

可以在TEST 或者TEST_F宏的testcase名字前加入关键字DISABLED_ 用来屏蔽某些测试用例,你也可以屏蔽一个TESTSUITE或者TESTFIXTURE,操作方式相当于一个全局的替换,将X替换为DISABLED_X
--gtest_also_run_disabled_tests  可以临时运行被DISABLE的测试用例,而不用修改代码
--gtest_repeat=n   重复执行测试代码n次,n=-1会一直执行
--gtest_shuffle     以随机的顺序执行测试用例

执行单元测试,默认的结果是在命令窗口打印,有时候我们可能需要文件性质的结果报告,gtest同样也提供了生成xml和json格式的报告选项:
选项:--gtest_output  
--gtest_output=xml
--gtest_output=json
会按照xml或者json的格式将结果保存在本地,保存的名称为 test_detail.json或者test_detail.xml;

如果需要将报告生成到指定目录:
--gtest_output=xml:/usr/local
--gtest_output=json:/ussr/local

生成的文件名称为 exe_TEST.json或者exe_TEST.xml,这里的exe代表执行的测试程序的名称;也可以在目录后跟想要生成的名称
--gtest_output=xml:/usr/local/a.xml
--gtest_output=json:/ussr/local/a.json

Thanks for reading ~

你可能感兴趣的:(单元测试框架gtest使用研究)