关于单元测试的一些思考

来源:互联网 发布:莫言最好的作品 知乎 编辑:程序博客网 时间:2024/04/28 17:03

1.    单元测试,持续集成在敏捷开发中的位置:

    敏捷开发(XP,Scrum)中提到了很多项目管理的实践。其中最基本的四个技术实践是其他实践活动的基础:版本控制,单元测试,持续集成与重构;

    单元测试是持续集成与重构的基础。持续集成通过自动化的单元测试来验证每次提交代码的正确性,重构也是通过单元测试来保证重构后的代码能够完成重构前同等的功能;

持续集成的流程大致如下:

    1) 每过指定时间(5分钟)从版本控制系统上拉去最新的工程;

    2) 如果更新到新的内容,通过自动化编译脚本编译代码;

    3) 编译通过后自动运行自动化测试;

    4) (options)还可以选择性的运行静态代码分析,内存检测,测试覆盖率检测等工具;

    5) 将编译和测试过程中产生的错误等信息生成成报告,发布到Dashboard,或邮件通知到各个开发人员。

    6) 每天夜晚指定时间的重新构建整个工程,并在所有测试全部通过后,运行自动部署的脚本,将当天的版本发布到指定位置(FTP服务器)。

 

 

2.    单元测试的概念:

 

虽然单元测试的库有很多,但是大部分都使用一些统一的概念。下面是对单元测试中一些统一概念的描述:

1)  测试断言(TestAssert)

    测试断言是用来判断被测试的对象是否符合预期的工具;最简单的使用方法如下:

XX_CHECK(add( 2,2 ) == 4);

XX_CHECK_MESSAGE(add( 2,2 ) == 4, “add is failed.”);

XX_CHECK_EQUAL(add( 2,2 ), 4);

        好的单元测试库提供了一组足够丰富的测试断言来判断各种情况,比如CHECK_EQUAL可以正确的处理比较字符串和比较浮点数要注意的情况,CHECK_THORW_EXCEPTION可以用来预测调用一定会抛出指定的异常等;

    2)  测试用例(TestCase)

    测试用例是单元测试的基本单元,用于对同一个对象的单一功能进行测试,并利用测试断言来检测该功能是否符合预期。

测试用例的写法有两种学派:一种提倡一个测试用例中只包含一个测试断言,这样比较强制的保证了单一测试用例只对一个功能进行测试;另一种则认为只要是属于同一功能的,可以在一个测试用例中写上多个测试断言;

在实际的使用中,第二种方式应该更加方便,应用得也比较多;

    3)  测试套件(TestSuite)

    测试套件用于对TestCase进行分类组织,一般常见的做法是将TestSuite作为一种树状的结构,树的子节点就是实际的TestCase。这样有点类似于操作系统中队文件系统路径的分类方式;

这样一个测试执行程序可以通过运行参数来控制一次运行其中的哪部分测试用例,可以为在不希望运行全部测试用例(比如在重构某个类的时候,不希望每次检测的时候还花费时间去运行其他类的测试)。

    4)  测试夹具(TestFixture)

    由于前面建议每个测试用例只测试一个功能,这样可以将各个测试独立开。但是碰到的问题是重复代码。因为很多测试都需要构造同样的对象,只是测试的时候调用的函数不同而已;这时候可以使用测试夹具来统一每个测试用例的初始化和清理的过程;

有的测试工具还支持测试夹具不只能在每个测试用例的层次上进行,也可以在测试套件和整个进程级别的测试用例;

    5)  接缝模型:

    单元测试是以类为单位进行的,在测试比较上层类的时候,会涉及到对很多低层类的依赖。有些低层的类构造起来代价很高,而且功能不稳定,例如渲染接口,网络连接,资源文件读取等。

这时候为了能够构造需要测试的对象,需要想办法在测试环境中替换掉被测试对象依赖的类。C++中能够使用的接缝有很多种类,包括预处理期的接缝,编译期接缝,链接期接缝和对象接缝。

    6)  Mock对象与Fake对象:

    替换依赖对象的实现有两种:Mock Object和Fake Object。这两个之间有细微的区别:

Fake对象会实现父类的基本要求,但是比其他的实现更加容易构建。例如一个数据库连接的实现是去实际的连接数据库,而它的Fake实现可能是一个内存数据库。

    Mock对象则不在于完成功能,而只是用于记录方法调用的次数,参数,次序等,并用于判断是否和预期符合。

 

3.    单元测试的编写方式:

为了实现单元测试,并不只是编写测试代码一方面的问题,而是需要再设计程序结构的时候就要开始考虑如何进行单元测试;下面将澄清几个关于单元测试的常见误解,并提供一些如何正确编写单元测试的方案:

1)  单元测试的目标是单个的对象,而不是整个系统;

单元测试执行的时候,不应该要求有复杂的配置,比如还需要配置数据库,或者启动多台协作服务器之类的。这种全系统的测试时集成测试要做的事情而非单元测试。单元测试更像是我们平时调试代码的过程:一行一行的跟踪程序的执行,观察其中的变量是否符合预期的变化。单元测试要做的是把这个过程用测试代码的方式记录下来;

2)  面向对象系统的单元测试,最重要的是要能单独构造出一个对象。

如果是位于系统底层的对象则比较方便,但上层控制逻辑的对象,则需要使用一些接缝模型来制造一些伪对象(MockObject)来模拟该对象所依赖对象的行为;最常见的接缝模型就是虚函数;

因此在设计一个类的时候,必须考虑为了方便在单元测试中构造出这个类,提供怎样的接口可以伪造出其依赖的类;

3)     单元测试重点是测试对象在执行功能过程中的数据正确性;

单元测试常常不用关心整个系统运行起来是否正确,而只检测某个类在运行过程中是否正确。而且由于会使用到mock对象,单元测试中往往看不到该类最终的结果。例如一个Animation类的测试不会要求需要渲染出动画(即使渲染出来的也没法自动化的检测渲染结果的正确性),因为可能底层的渲染接口都是假的。

单元测试需要做的只是检测Animation在使用了某个方法后,其中的状态(变量)是否按照期望的进行了改变,并且Mock对象应该能够记录下Animation类对其的每次调用,然后判断这些调用是否符合预期;

4)  不必为太过简单的方法编写单元测试:

对于Get这种取值函数,就没有必要但编写一个测试用例了。但一些基础的库比如数学库可能是个例外;

5)  封装性与测试性之间的冲突:

有时候可能有的函数不希望使用者调用到,而写成private函数,但测试中确希望能单独测试这个函数,因为更小功能的函数更容易进行测试,并校验其正确性;

一般来说出现这种情况时,最阳春白雪的方案是想办法把这个类拆掉,其中private的功能函数集成进另一个类中。

当然很多时候这样做并不值得,类的数目过多的时候也会使程序不容易理解。如果只是一个简单功能的private,或者某个内部变量的值不希望提供接口给外部访问,在封装性和测试性之间宁愿牺牲掉这一小部分的封装性。也可以考虑使用一个宏开关来控制,编译测试版本的时候为public,正常编译的时候为private。

6)  如果发现一个类难以编写测试:

有可能是这个类太大了,或者这个类与其他某个类有循环依赖,或者是依赖的对象没法构造Mock对象的接缝。这些在很多情况下也是一般软件设计应该避免的问题,如果一个类发现难以编译测试,往往意味着需要改进设计;

7)     可测试性与效率:

对象的接缝在面向对象中很多都是以虚函数实现的。以虚函数构造的对象主要面临着两个问题:虚函数转调的开销和堆分配的开销。

由于希望在多种不同对象直接切换,虚函数的转调无法避免。而且由于每个对象不同的实现需要的空间不同,这种对象一般是以堆分配的方式构造出来的。但是很多时候其实我们并不需要这些开销,有的对象没有必要引入虚函数,或者可以直接写在栈上(或者说,持有该对象所有权的类直接持有这个对象而不是这个对象的指针,然后动态分配出来)。

但是事实上在目前的电脑上,这些开销并不算太明显,特别是在游戏中,渲染和AI等占用了大量CPU资源后,这些结构化开销所占的比例就相对比较小了。Java中很好写单元测试就是Java中几乎所有对象都是堆分配的,而且大量使用虚函数。

另外那些效率相关的对象(比如数学库等)一般都位于系统的底层,没有太多的依赖,因此这些对象也不会受此影响;

如果实在是无法接受虚函数的性能开销,而测试又需要运行期的接缝模型,一个可能的选择是使用一个宏来控制virtual的启用与否,并为测试和发布分别编译不同的版本.

如果真的发现某些对象因为性能原因,无法构造虚函数的接缝,还有许多编译期的接缝方式可以考虑。具体可以参考《修改代码的艺术 Working Effectively with Legacy Code》

                    过早的优化时万恶之源;

 

4.    单元测试库的选择:

    单元测试库是否合乎使用有一些衡量标准,大致的一些要求如下(按照个人认为的重要性排序):

    1)         容易学习和使用;

    2)         添加一个新的测试用例要快速方便;

    3)         编译速度要快;

    4)         方便灵活的测试断言(TestAssert);

    5)         支持测试夹具(TestFixture)来统一多个同类型的测试用例(TestCase)的初始化和销毁过程;

    6)         支持使用测试套件(TestSuite)组织测试测试用例;

    7)         不依赖其他的外部库;

    8)         可以捕获系统异常和程序错误(例如崩溃等);

    9)         对大型的测试能够看到测试进行的进度;

    10)     支持扩展,例如错误报告格式等;

    在网上有一篇Wealth.Zhou写的名为《探秘C++单元测试框架丛林》的文章列出了他所认为单元测试应该具备的特征,基本和上表相同。其中也详细分析了许多测试框架。该文章可以在这里找到:http://www.uml.org.cn/test/201006085.asp

这里我提供自己的一些建议:

    CppUnit和CppUnitLite:

    可以确定的是CppUnit不必考虑了,因为在CppUnit中添加一个TestCase的成本太高,还需要自己构造一个类。可以预想这样的结构肯定会极大的打击大家编写单元测试的兴趣;

    CppUnitLite是一个非常简化的单元测试版本,一共只有6个类,13个文件。每个文件长度都不超过100行。甚至可以直接源代码包含进测试程序中也不会慢。添加新的测试也非常简单,不过功能也不对,对测试套件,捕获系统异常和定制错误输出,扩展等方面基本没有支持,测试断言的类型也太少了(甚至没有检查抛出异常的assert)。

    Boost.Test

    Boost.Test是一个相当完整的框架,在上面提到的各个方面都有良好的表现。而且Boost.Test库在文档方面也是非常全面,连单元测试的原理和实践方法都讲了一大堆。唯一的问题在外部依赖库上。作为Boost 组件的一部分,这个测试框架意料之中的运用了一些boost相关的其他内容。

    GoogleTest与GoogleMock

    GoogleTest库的功能其实和Boost.Test的功能非常相似,只不过在依赖性方面会好一些,不会像boost.test一样会依赖boost的其他组件。不过这套方案最大的特点是GoogleMock这个库,可以帮忙构造Mock对象。(PS.不过GoogleMock库也要求有tr1,如果使用vs2005的话,也是需要boost的支持……)