实用单元测试技术

来源:互联网 发布:德国双手剑 淘宝 编辑:程序博客网 时间:2024/04/29 21:50
  
前言
单元测试是高收益难实施的过程改进。之所以难实施,一个重要原因是理论与应用严重脱节,例如白盒覆盖,一般理论会介绍六七种覆盖率统计公式,并以极简单的代码为例,介绍实现这些覆盖的方法,但要将这些方法应用到普通的三四十行代码的程序中,根本就不现实。越复杂的程序越需要充分测试,只能应用于简单代码的测试技术有什么意义呢?
    理想的单元测试理论体系应该符合以下标准: 
    实用,即能应用于实际项目,能测试复杂的代码,并经过实际应用的检验;
    效果,即发现代码错误的效果要好,并要有可衡量的数字指标;
    效率,即花费的时间要少。
    这里提出一套基本符合上述要求的单元测试技术与方法,这套理论以实现"零时间彻底测试"的目标,"零时间"即不增加专门的单元测试时间;"彻底测试"即保证测试的完整性,并可用数字指标来衡量。
    "实用"是这套理论的第一特征,即便于在复杂的项目中实施,当然,要依靠基于这套理论的工具来实现。单元测试工具通常是语言相关的,目前,基于这套理论的C/C++单元测试工具已经历了应用检验。
    理论具有共性,这套理论本身与具体语言无关,具有广泛的参考价值,同时也具有开放性,欢迎广大用户、理论界、测试工具开发厂商、教学培训机构等共同参与,丰富和发展实用高效的单元测试理论。 
    示例代码均为C++语言,开发环境为VC6.0。
第一章 单元测试的必要性和效益
1.1 什么是单元测试?
    工厂在组装一台电视机前,会先测试所有元器件,与此类似,软件开发过程中也要对各个代码单元进行单独的隔离的测试,这就是单元测试。
    "代码单元"一般是指类或函数,流行的看法是以类为"单元",但在实际工作中,以类为测试单元并不实用,即使是比较简单的类,例如只有三五个成员变量、十几个成员函数的类,如果作为一个整体来测试,做起来也过于复杂,因此,本文主张以函数为测试单元。
1.2 单元测试的必要性
    缺少单元测试是项目质量不符要求,工期超时,预算超支的主要原因之一。如果缺少单元测试,编码完成后看到的往往不是胜利的曙光,而是噩梦般的泥潭。不说别的,仅仅"对代码的修改很可能会引入新的错误"这一点,就足于导致项目失败。
    缺少单元测试,代码就会遗留大量的细小的错误,集成后就算能把这些错误找出来,总要修改吧?这就可能陷入修改,引入新的错误,测试,再修改,再引入新的错误的怪圈。 
    需求的变化几乎是不可避免的,因为很多功能都是做出来、试用过才知道有没有用、好不好用。需求的变化必然导致大范围的代码修改,于是又可能陷入修改,引入新的错误,测试,再修改,再引入新的错误的怪圈。
    有了单元测试,情况就完全不同,首先单元测试使代码中的错误减至最少,其次任何修 改都可以通过自动回归测试来发现所引入的错误,从而消除修改所造成的破坏。
    如果要保证项目质量,避免工期超时、预算超支,那么,实施单元测试就是完全必要的。
1.3 单元测试的效益
保证局部代码的质量:单元测试在隔离的前提下,分别对各个代码单元进行测试,能够达到其他测试不可能达到的测试完整性,从而保证了局部代码的质量。只有局部代码的质量得到了保证,软件产品的质量才可能得到保证。
    改良项目代码的整体结构:要对代码进行单元测试,最起码的前提是代码能够隔离,也就是说,要具有一定的可测性,因此,单元测试是一种有效的约束机制,这种机制将有效地改良代码的整体结构。例如,如果把业务代码直接写在界面类中,将很难进行单元测试,随意的不合理的紧耦合也会造成难于测试,单元测试使这些不好的特性得于及时发现,从而很容易进行修正。可测性是高质量代码的首要特性,不具有可测性,也就无法衡量代码的正确性,有了可测性,也就基本上保证了代码的可扩展性、可复用性。
    降低测试、维护升级的成本:错误越早发现,修复的代价越小,另一方面,如果代码经过了充分的单元测试,集成测试和系统测试就只需要关注设计方面的问题。自动回归测试也大量降低升级维护成本。
    使开发过程适应频繁变化的需求:单元测试自然地使开发流程变得"敏捷",这是因为,整体结构良好的代码具有较好的可扩展性,自动回归测试又能保证修改不会引入新的错误,因此可以适应频繁变动的需求,降低系统分析、架构设计和后期测试的压力。
    提升程序员的能力:对程序员来说,单元测试有利于养成缜密的思维习惯,提高编写健壮代码的能力,并提高设计能力。
第二章代码错误的分类及特点
2.1 功能错误与性能问题
    代码错误可分为功能错误和性能问题。
    性能分为空间性能和时间性能,空间性能主要是指占用内存和磁盘空间的多少,内存泄漏造成了不必要的内存占用,因此也是空间性能问题;时间性能主要是指占用CPU的时间。性能问题一般通过针对软件整体或大的模块的测试来衡量,不属于单元测试的范畴,例如,针对一个类或一个函数,很难确定是否存在内存泄漏。
    功能错误就是程序没有按预期工作,包括程序崩溃、输出不正常等等,找出这类错误是单元测试的主要目标,后文所说的"错误"或"代码错误",都是指功能错误,不包括性能问题。
2.2 代码错误的分类
    了解代码错误的分类及特点,有助于选择合适的测试方法和测试工具。
    从单元测试的角度来看,代码错误可分为有特征错误和无特征错误,有特征错误又分为语法特征错误和行为特征错误。
    语法特征错误并不是语法错误,语法错误会导致编译失败,语法特征错误是指能通过编译,但是根据代码中的某些特征可以判断代码含有或可能含有错误,例如if(N=0),其特征是条件式中出现了赋值操作符,静态分析工具据此可以给出警告,现代编译器通常也会给出警告。
    行为特征错误是指具有可捕捉的行为特征的错误,这类错误通常会导致程序崩溃或产生异常。例如给代码中给一个指针赋值,如果指针为空,程序就会崩溃,这就是行为特征错误。
    除了有特征错误外,其他就是无特征错误了。例如一个加法函数 int Add(int a, int b){return a-b;};,错误是将加号写成了减号,这种错误不具有任何语法或行为特征。
2.3 有特征错误的特点
    有特征错误具有如下特点:
    容易自我暴露:现代的编译器通常会对具有语法特征错误的代码发出警告,行为特征错误通常会导致程序崩溃,从而将错误暴露出来。
    易于定位和调试:语法特征错误一般是很容易定位和修正的,行为特征错误也比较容易定位,根据程序的崩溃地址一般就能定位到源代码,错误源与错误暴露的位置相隔一般也不太远。
    数量较少:有特征错误和无特征错误各占多少比例呢?很难有一个准确的数据,不同的团队,不同的开发人员,比例会有所不同,一般来说有特征错误不会超过百分之二十,读者可以根据自己的经验评估一下。
    可能被自动测试工具发现:有特征错误具有可识别的语法特征或可捕捉的行为特征,因此可能被自动测试工具发现。
    总之,有特征错误对于用户来说,当然是不可接受的,但对于开发商来说,发现并消除有特征错误的成本相对较低。
2.4 无特征错误的特点
数量多:代码错误大多属于无特征错误。
    不能被自动测试工具发现:由于不具有可识别的语法特征或可捕捉的行为特征,自动测试工具在没有人工干预的前提下无法发现无特征错误,因为测试工具不可能自动了解程序的功能。
第三章 基本测试方法
3.1 单元测试的基本方法
    单元测试的基本方法有:人工静态分析、自动静态分析、自动动态测试,人工动态测试。
    人工静态分析:通过人工阅读代码来查找错误,一般是程序员交叉查看对方的代码,可能发现有特征错误和无特征错误。
    自动静态分析:使用工具扫描代码,根据某些预先设定的错误特征,发现并报告代码中的可能错误,自动静态分析只能发现语法特征错误。
    自动动态测试:使用工具自动生成测试用例并执行被测试程序,通过捕捉某些行为特征(如产生异常/程序崩溃等)来发现并报告错误,自动动态测试只能发现行为特征错误,对无特征错误完全无能为力,例如,前面所说的加法函数,代码可以说是最简单的,错误也是最简单的,但是自动动态测试仍然无法发现,因为测试工具不可能自动了解代码的功能。
    人工动态测试:人工设定程序的输入和预期的正确输出,执行程序,并判断实际输出是否符合预期,如果不符合预期,自动报告错误。这里所说的"人工",仅指测试用例的输入和预期输出是人工设定的,其他工作可以由人工完成,也可以借助工具自动完成。人工动态测试可以发现有特征错误和无特征错误,例如,前面所说的加法函数,只要人工建立一个测试用例,输入两个1,并判断输出是否等于2,运行测试,就可以发现代码中含有错误。
    以上四种方法还可以进一步细分,例如,人工动态测试又有多种设计测试用例的方法,如果根据程序的功能来设计测试用例,就是黑盒测试,如果根据代码及代码的逻辑结构来设计测试用例,就是白盒测试。
3.2 测试方法的选择
    工作中是不是把各种测试方法不分轻重都做一遍呢?显然不行,项目工期和预算不会允许这么做,也不符合效益原则,应该选择一种方法作为主要测试方法,其他视情况取舍。
    自动静态分析、自动动态测试只能发现有特征错误,这两种方法加起来,做到最好也仅限于发现有特征错误,而多数语法特征错误编译器就能发现,很多行为特征错误会在开发过程中,或集成测试和系统测试中自动暴露出来,所以这两种方法不宜作为主要测试方法。
    人工静态分析虽然可能发现有特征错误和无特征错误,但是要彻底找出所有错误来,显然太难了。
    人工动态测试可以发现有特征错误和无特征错误,并且具有广阔的发挥空间,可以作为主要测试方法。
3.3 黑盒测试与白盒测试
    常常见到"单元测试是白盒测试","单元测试也有黑盒"之类的说法,容易引起混乱。黑盒与白盒其实是测试方法,黑盒就是针对系统的外部特性进行测试,把目标系统看作一个黑盒子,不考虑内部结构;白盒就是针对系统的内部结构进行测试。各个测试阶段都可以使用黑盒方法和白盒方法,即无论是单元测试、集成测试、系统测试阶段都可以使用黑盒方法和白盒方法。
    黑盒测试又叫功能测试,我们首先要测试程序是否实现了基本功能,因此,黑盒测试是基本测试。黑盒测试的主要缺陷是难于衡量完整性,而白盒测试正好可以弥补个缺陷。
    白盒测试通过逻辑覆盖率来衡量完整性,具有可以精确统计的数字指标。逻辑单位主要有:语句、分支、条件、条件值、条件值组合,路径。语句覆盖就是覆盖所有的语句,其他类推。另外还有一种判定条件覆盖,其实是分支覆盖与条件覆盖的组合。跟条件有关的覆盖就有三种,解释一下:条件覆盖是指覆盖所有的条件表达式,即所有的条件表达式都至少计算一次,不考虑计算结果;条件值覆盖是指覆盖条件的所有可能取值,即每个条件的取真值和取假值都要至少计算一次;条件值组合覆盖是指覆盖所有条件取值的所有可能组合。与条件直接有关的错误主要是逻辑操作符错误,例如:||写成&&,漏了写!什么的,采用分支覆盖与条件覆盖的组合,基本上可以发现这些错误,而条件值覆盖与条件值组合覆盖往往需要大量的测试用例,因此,条件值覆盖和条件值组合覆盖的效费比偏低,比较有价值的覆盖率是语句覆盖、条件覆盖、分支覆盖、路径覆盖。
3.4 测试用例
    人工动态测试需要人工设计测试用例。一个测试用例,就是设定输入数据,执行被测试程序,并判断输出是否符合预期。输出符合预期,则测试通过,否则测试失败。一般来说,测试工具要能自动报告失败的测试。
    测试用例的主要内容是输入数据和预期输出,简称输入输出,其中输入是核心,输入确定了,再根据程序的功能设定预期的正确输出。
    如果我们把函数看作测试单元,那么,输入数据就是被测试函数所读取的外部数据及这些数据的初始值。"外部数据"是对于被测试函数来说的,就是除了局部变量以外的其他数据,分为几类:参数、成员变量、全局变量、IO媒体。IO媒体是指文件、数据库或其他储存或传输数据的媒体,例如,被测试函数要从文件或数据库读取数据,那么,文件或数据库中的原始数据也属于输入数据。
    预期输出是指:返回值及被测试函数所写入的外部数据的结果值。返回值就不用说了,被测试函数进行了写操作的参数(输出参数)、成员变量、全局变量、IO媒体,它们的预期的结果值都是预期输出。
    一个函数无论多复杂,都无非是对这几类数据的读取、计算和写入,我们的测试目的,就是要检验程序的这些行为是否符合要求。
总而言之,输入就是被测试程序要读取的数据,我们要设定它们的初始值,输出就是被测试程序要改写的数据,我们要判断它们的结果值是否符合预期。
    我们只需要关注外部数据中真正需读取或写入的部分,不必理会其他部分,例如,一个参数,它是一个结构,有十个域,但是被测试函数只需要读取其中的一个域,那么测试用例只需要为这个域设定初始值;再如,被测试类中有十个成员变量,但是被测试函数只需要读取其中的一个,那么测试只需要为这个成员变量设定初始值。对预期输出的判断也一样。
3.5 测试代码
    为了简单和实用,建议这样组织测试代码:一个产品类对应一个测试类,一个产品函数对应一个测试函数,一个测试函数内包括若干个测试用例。
    下面是函数CMyClass::Add(int a, int b){return a+b;};的测试函数:

    void CMyClassTester::Add_int_int()
    {
        //第一个测试用例
        { CMyClass* pObj = new CMyClass(); //1
        int i = 0; //2
        int j = 0; //3
        int ret = pObj->Add(i, j); //4
        ASSERT(ret == 0); //5
        delete pObj; } //6
    }

    把参数表作为测试函数的函数名的一部分,这样当出现重载的被测试函数时,测试函数不会产生命名冲突。第1和第6行建立和销毁被测试对象,所加的{}是为了让每个测试用例的代码有一个独立的域,以便多个测试用例使用相同的变量名。第2和第3行是定义输入数据,第4行是调用被测试函数,第5行是预期输出,它的特点是当实际输出与预期输出不同时自动报错,ASSERT是VC的断言宏,也可以使用其他类似功能的宏,使用测试工具进行单元测试时,可以使用该工具定义的断言宏。更多的测试用例可以通过拷贝现有用例并修改输入输出的方法建立。
    前面的例子是使用产品代码所用的语言来编写测试代码,还有一种方式是使用脚本语言来编写测试代码,后者的优点是无需编译即可执行测试,但缺点也非常明显:需要另外掌握一种脚本语言;复杂数据类型处理起来比较麻烦;前置操作和后置操作很难处理(要使用一种语言去调用另一种语言的代码);在两种语言间来回切换会严重干扰人的思维,边开发边测试调试时,这种干扰思维所损失的时间可能比免编译所节约的时间多得多。
    至于测试数据的组织,前面的例子是直接保存于代码中,另一种方式是保存于电子表格中(包括保存于数据库中),后一种方式对于测试工具开发商来说无疑是一种好选择,因为数据是独立的,测试工具容易保证新旧版本的兼容性,降低了工具开发难度,但却难于适应复杂的应用。测试这样的代码:int Add(int a, int b){return a+b;};数据怎么组织都行,但实际应用却是很复杂的,例如,输入数据是复杂类型,可能只需要为其中的一个域设定初始值,设定初始值的途径又可能是调用对象的接口;输入数据可能需要经过中间处理;需要执行前置操作;输出要判断多个数据的结果值,这些数据又可能是高级类型,需要调用其接口来判断状态;需要执行后置操作等等,这些输入输出实际上就是代码,写在表格中显然极不方便,勉强写在表格中,使用时不可避免会产生大量的编译或解析错误,要反复地修改,反复导入,反复编译或解析,效率低下,难于适应实际的应用。
3.6 代码的可测性
具有良好整体结构的代码,应该符合"低耦合"的特性,形象地说,就是"各家自扫门前雪、不管他人瓦上霜",每一个函数、每一个类、每一个模块,都应该只做自己该做的事,不要把应该由"其他人"做的事扯进来。 通常,"低耦合"的代码具有可测性,具有不当高耦合特性的代码通常不具可测性,一般来说,如果代码不具可测性,那么加入测试工程后,会产生编译错误,导致测试无法进行。
    高质量代码应该具有正确性、可测性、可复用性、可扩展性。可测性是最基本特性,能测试才能检验和提高正确性,具备了可测性,也就具备了最基本的可复用性和可扩展性。解决不当耦合的较好手段是重构,即改良代码结构,这样不但能顺利完成测试,更重要的保证了代码具有良好的整体结构,使代码易于维护和复用。
    用打桩来解决可测性问题也是一种思路。打桩是单元测试的常用技术,可用以将影响测试的外部系统隔离:一是将不可控的、费时的实际系统隔离,例如访问网络、数据库、硬件等,二是将复杂的底层系统隔离,采用相对简单的模拟系统来代替,但用打桩尤其是工具自动打桩来解决不当耦合造成的可测性问题,则不见得实用,举个例子来说明:

    void Sort(int[10] data)
    {
        …
        Swap(int[a], int[b]);
        …
    }

    Sort是排序函数,调用了Swap实现两个数的交换,假如用一个自动生成的桩来代替Swap,那么这个桩通常是什么也没做,只是能通过编译而已,这样,两个数的交换过程根本就没有完成,整个Sort函数的功能完全不同了,这样的测试,除了看一下程序是否会崩溃之外,还有什么意义呢?要做到打桩后不改变代码的功能,并非易事,并且打桩并未改良产品代码的结构,还是重构更有价值,下面是几种典型的不当耦合及重构方法:

    把代码写在界面类中
    问题:如果把业务代码写在界面类中,测试时把界面类加入测试工程,会产生编译错误。
    解决:把业务代码独立出来,写到相应的实体类中,对这些实体类进行测试。界面类只负责数据的显示和接受用户的输入,具体的计算由实体类负责。
    说明:把业务代码写在界面类中,这些代码将很难管理和维护,复用就更谈不上了,这是一定要避免的。

    实体类混合了边界代码
    问题:例如,一个表示用户的类CUser,它的对象的数据保存在数据库中,如果在CUser类中直接读写数据库,就是实体类混合了边界代码,这也是一种不当耦合,测试CUser类时要察看数据库,或者要打桩,甚至会产生编译错误。
    解决:把执行数据库操作的代码写到CDatabase类中,CUser类和CDatabase类没有任何关联,由界面类或用于协调各对象工作的控制类来操作这两个类的对象进行数据读写,现在对CUser类的测试就完全和数据库无关了。
    说明:经过重构后,代码的可扩展性、可复用性都有了很大提高,也便于进行单元测试和将来的维护。

    无意中形成了高耦合
    问题:例如,CMyClass类中一个函数中有这样的语句:
    CDemoApp* pApp = (CDemoApp*)AfxGetApp(); 
    UNIT var = pApp->GetXXX()->Getxxxx(); 
    由于CDemoApp是工程的最高层类,可能跟工程中的所有类关联,这两行代码就造成被测试对象跟工程中的所有类耦合。耦合具有扩散性,纵向来说,CMyClass类的所有子类也可能跟工程中的所有类关联,横向来说,所有使用了CMyClass类的类,也可能跟工程中的所有类关联,从而形成了盘根错节的关系。这种问题往往很难发现,但把代码加入测试工程后,一般来说是通不过编译的,从而使问题暴露。
    解决:修改被测试代码,去除耦合。把需要从pApp指针中取得的数据改为由参数传进来,这两行代码移到客户程序(即调用被测试函数的代码)中,如果客户程序也是实体类,则继续往上移,直到界面类。
    说明:这类无意中形成的耦合非常常见,一般的代码审查、静态扫描都很难发现这类问题,但把代码加入测试工程后,编译器却能轻松发现。这类问题只要及时发现了,消除并不难,经过简单的代码重构,就可以有效地提高代码质量,特别是提高代码的可复用性,也使单元测试可以顺利进行。

    开发工具生成的代码形成了不当耦合
    有时候,开发工具生成的代码也形成了不当耦合,例如,VC6.0生成的类代码中,源文件会添加下面的代码:
    #include "ProjectName.h" //ProjectName是工程名
    如果在另一个不同名的工程中复用代码,这一行就会产生编译错误。进行单元测试时,这一行形成的不当耦合也可能会导致编译错误,因此应删除它。
第四章彻底测试的方法
4.1 彻底测试的可能性
    有一种说法是,"测试总是不彻底的",对于软件整体来说,要做到彻底测试确实很难,但对于局部代码,则是完全可能的。先说 "理想的彻底测试",对于一段程序,比如一个函数,如果它的所有可能输入都测试过,并且都没有错误,那么可以肯定,这个函数是没有错误的,这就是 "理想的彻底测试"。"理想的彻底测试"是做不到的,有没有 "现实的彻底测试"?有!"现实的彻底测试"就是所有的等价类都经过测试。

    先说说等价类。"类"是指把输入数据分组,"等价"是指测试效果上的等价,"等价类"就是在一组输入数据中,任取一个进行测试,如果测试通过,那么,可以认为同组的其他输入数据也可以测试通过,也就是说,测试了一个,就等于测试了一组。如果把所有的等价类都找出来进行测试,就相当于所有输入都经过测试,这就是"现实的彻底测试"。

    对于一段程序来说,"等价类"通常是有限的,因此,从理论上来说,对局部代码进行"现实的彻底测试"是完全可能的。
4.2 等价类划分的基本方法
    通常从三个方面考虑程序的输入:正常输入,边界输入,非法输入。  
    正常输入:例如字符串的Trim函数,功能是将字符串前后的空格去除,那么正常的输入可以有四类:前面有空格;后面有空格;前后均有空格;前后均无空格。
    边界输入:上例中空字符串可以看作是边界输入,如果输入是一个指针的话,空指针也算是边界输入。再如一个表示年龄的参数,它的有效范围是0-100,那么边界输入有两个:0和100。
    非法输入:非法输入是正常取值范围以外的数据,或使代码不能完成正常功能的输入,如上例中表示年龄的参数,小于0或大于100都是非法输入,再如一个进行文件操作的函数,非法输入可能有:文件不存在;目录不存在;文件正在被其他程序打开;权限错误。
    一般情况下,只要考虑:有哪些正常输入?有哪些边界输入?有哪些非法输入?就可以找出大多数等价类,例如,一个函数,功能是把小写金额转换成大写,输入是小写数字,输出是大写金额,那么:
    正常输入有:只有整数,只有小数,既有整数又有小数
    边界输入有:整数部分很大,0.0,小数位数超过两位
    非法输入有:空串,非数字,负数
    "等价类"这个词是从测试的角度来说的,从开发的角度来看,"等价类"与"功能点"具有对应关系,例如,字符串的Trim函数,功能点有:
    如果只有左边有空格,返回删除左边空格后的结果(等价类:左边有空格);
    如果只有右边有空格,返回删除右边空格后的结果(等价类:右边有空格);
    如果两边都有空格,返回删除两边空格后的结果(等价类:两边有空格);
    如果两边都没有空格,返回原串(等价类:两边无空格);
    如果是空串,直接返回(等价类:空串);
    如果是空指针,直接返回(等价类:空指针);
    ……
    一个"等价类"对应程序的一个"功能点",如果程序的所有功能点都正确实现了,那么这个程序的功能就肯定没问题。程序员在写代码时肯定要想清楚程序的主要功能点,否则代码无从写起,因此,如果程序员边编码边测试的话,大部分等价类都是现成的。如果由测试部门做单元测试,测试部门只能依据设计文档来测试,设计文档也会规定程序的功能,要不然就无从测起,同样可以说大部分等价类是现成的。
4.3 彻底测试的基本思路
    前面说过:如果把所有的等价类都找出来进行测试,就相当于所有输入都经过测试,这就是"现实的彻底测试"。
    如何把所有的等价类都找出来?根据程序的功能,大部分等价类都是容易想到的,甚至是现成的,但是,如何衡量是否完整?例如,前面所举的两个例子:删除字符串两边空格的函数和将金额小写转大写的程序,我们可以肯定,等价类最多也就十几个,相当有限,但是,已经列出的等价类是完整的吗?有没有遗漏的?
    要找出所有等价类,关键是要有衡量完整性的指标。等价类实际上就是程序的功能点,程序的功能是人为规定的,很难衡量完整性,但白盒测试却很容易衡量完整性,因此,我们可以考虑使用白盒测试的覆盖率来衡量测试完整性。
    看一下这个简单的例子:

    void Func(int* p)
    {
        if(p)
        {
            *p = 0;
        }
        else
        {
            return;
        }
    }

    参数p是一个指针,测试时当然要将空指针作为一个等价类,如果漏了这个等价类,会怎么样呢?分支覆盖会不完整:else分支未覆盖。从这个例子可以看出,未覆盖的逻辑单位通常对应未测试的等价类,因此,白盒覆盖可以衡量等价类是否完整。
    还是上面的例子,假如程序员完全忘了有空指针这回事,把代码写成这样:

    void Func(int* p)
    {
        *p = 0;
    }

    由于判断p是否为空指针的代码不存在,白盒覆盖当然不会提示说"某某代码或某某分支未覆盖",因此,白盒覆盖不能发现"程序员未处理某些特殊输入"这种情形,也就是说,即便达到了无与伦比的白盒覆盖率,仍然不能保证找出所有等价类。
    程序员会忘记处理哪些输入呢?常见的输入一般是不会记的,否则程序的起码功能都未实现,容易忘记的是一些"偏僻"的输入,例如,空指针、空字符串、很大的数、很小的数、合法取值边界附近的值等等,从输入的角度来看,这些特殊值通常跟数据类型有关,从程序的行为来看,这些特殊输入常常会导致崩溃、产生异常,或超时,即具有行为特征,正好是自动动态测试可以发现的,因此,我们可以利用自动动态测试来捕捉"程序员未处理某些特殊输入"形成的错误。
4.4 彻底测试方法
    基于上一节的思路,经过长时间摸索和改进,我们提出了实现彻底测试的"三步法":
    1. 根据代码的功能,人工设计测试用例进行基本功能测试;
    2. 统计白盒覆盖率,为未覆盖的白盒单位设计测试用例,实现完整的白盒覆盖,比较理想的覆盖率是实现100%语句、条件、分支、路径覆盖;
    3. 自动生成大量的测试用例,捕捉"程序员未处理某些特殊输入"形成的错误。

    第1步的测试用例通常是现成的,因为详细设计文档会规定程序的基本功能,没有文档的,程序员在编程时也要想清楚程序的功能,这些基本功能就是基本测试用例;
    第2步是在第1步的基础上,检查未覆盖的白盒单位,由于未覆盖的逻辑单位通常对应未测试的等价类,因此第2步可以找出第1步所遗漏的测试用例;
    第3步用自动动态测试弥补第2步的固有缺陷。

    "三步法"尽量避免重复工作,白盒方法和黑盒方法相结合,人工方法和自动方法相补充,如果第2步的覆盖率比较理想,那么基本上可以保证找出所有等价类。在开发过程允许的限度内,"三步法"已接近极限,当得起"彻底测试"四个字。
4.5 是否需要其他测试?
"三步法"使用了人工动态测试和自动动态测试,未使用人工静态分析和自动静态分析,要不要再使用这两种方法呢?这就要看合不合算了,毕竟项目时间和预算是有限的。
    "三步法"的测试完整性是空前的,读者可以比较一下,自己参与过的项目,所要求达到的覆盖率是怎么样的?做得比较好的,一般只是要求达到100%的代码覆盖,执行中还未必能做到,而"三步法"所要达到完整性是这样的:1)100%语句、条件、分支、路径覆盖;2)用自动动态测试捕捉未考虑某些特殊输入形成的错误。经过这种彻底测试后,遗留的错误可以说已经极少了。
    自动静态分析只能发现语法特征错误,现代编译器对多数这类错误会给出警告,另外,现有的自动静态分析技术会产生大量的误报,在一大堆报告中去一条一条人工辨别,寻找遗漏的错误,无异于"大海描针"。人工静态分析也差不多。先做这两项又怎么样?显然也是没必要的。
    为了统一代码风格,可以偶尔抽查代码,也就是说,可以用人工静态方法检查代码风格和编码规范。
第五章提高测试效率的途径
5.1 提高测试效率的基本思路
    提高测试效率的方法有两个:"节","挖"。"节"就是利用工具尽可能减少测试时间,"挖"就是挖掘编码工作和调试工作中的效率潜力,编码、调试与单元测试具有紧密的关系,利用单元测试工具可以提高编码和调试效率,从而抵消部分或全部测试时间。
5.2 减少测试时间
    要减少测试时间,首先要弄清楚哪些环节消耗时间最多,然后才能对症下药。单元测试最耗时的环节有:编写测试代码、建立测试用例、找出遗漏用例。
    测试代码具有很多固定的内容,例如都要建立和销毁被测试对象,执行被测试程序等,这些代码完全可以由工具自动生成,测试人员只要设定测试用例的输入输出就行了。
    如果测试用例也由工具自动生成当然最省时,可惜这是不现实的,因为测试工具不能自动了解程序的功能,只能依靠参数的数据类型之类的信息生成测试用例,生成的用例和实际需要往往相差很远。用工具自动生成大量测试用例然后手工修改怎么样?听起来不错,但用起来效率不高,因为很多输入输出是比较复杂的,必须由人工补充一些代码,如果预先生成大量用例,那么每个用例都是在相同的起点上修改,效率并不高。如果工具只生成第一个测试用例,由用户完善后,再用拷贝并修改的办法建立其他用例,那么就可以充分利用已完善的用例,如果每次都选择拷贝近似的用例,通常只需要修改一两个数据就能建立新的用例。
    找出遗漏用例是一个大难题,多数单元测试工具都只能统计出覆盖率,但是未覆盖的往往是最难的,要提高覆盖率往往成本很高,解决这个问题对提高测试完整性,降低测试成本具有极其重要的意义。这里提出一个新的思路:在运行现有的测试用例后,针对某一未覆盖的逻辑单位,由测试工具从现有的用例中计算出可以覆盖该逻辑单位的近似用例,再提供一些提示,由用户根据提示进行修改,从而获得预期的测试用例。这里说的近似用例是指需修改的数据最少,数值的差异也最小,例如,程序涉及到三个参数,那么近似用例大概只需要改一个参数就行,如果有多个用例可供选择,那么就选择数值改动幅度最小的。这个思路涉及到复杂的技术,这里就不详述了。
5.3 提高编码效率
    编程的主要时间消耗不在于敲键盘,而在于编程思路和调试。这里说的编码,主要是指编写有一定复杂度的函数,即局部代码的实现,这是单元测试的重点,也是编码阶段最主要的工作。
    程序员通常是在了解程序需实现的功能,并有了大概思路后即开始编码,而不是先将所有细节都想清楚。编码时也很难一气呵成,一般来说,写几行,就要编译调试一下,输出一些数据,看看已经写的对不对,再想下一步要怎么写,也就是说,要不断地了解已经编写的程序的行为,不断整理和完善编程思路。如果测试工具可以自动地描述程序行为,就可以帮助程序员更快捷地整理和完善编程思路,从而提高编码效率。
    程序行为是什么?无非就是三点:输入是什么?执行了哪些代码?输出又是什么?在测试结果中显示输入输出数据,及标示出所执行的代码,是完全可以做到的,对于较复杂的程序而言,这种功能可以成倍地提高编码效率。
5.4 使用测试代码调试
    调试是开发过程中无可逃避的工作,调试时必需执行程序,如何执行?一般由别的代码来调用,也就是说需要驱动,驱动大致可分为自然驱动和专门驱动。
    自然驱动:利用项目中已有的代码作为驱动,通常是在被调试的函数中加断点,从界面执行一个需要调用该函数的功能,调试器中断时就可以调试了;专门驱动:为需要调试的函数编写专门的驱动代码,通过执行驱动代码来执行被调试函数。
    自然驱动的主要优点是不需要其他工作就可以直接调试,甚至感觉不到需要驱动,主要缺点是输入数据通常是公共的,即很多代码都使用相同的输入源进行调试,实际输入往往是经过其他代码处理后的中间结果,要针对各种可能输入都进行调试往往很困难,造成调试不全面,程序员的思维受到局限,难于做到全面地考虑各种可能输入。
    专门驱动的主要优点是输入数据是专门针对于被测试程序,容易做到比较全面,程序员的思维也会比较全面,对编写功能齐全的健壮的程序很有好处,要针对某种特定输入进行调试比较容易,缺点是需要花费大量的时间来编写驱动代码。
    调试也需要"驱动"和"输入数据",也需要执行程序,这与测试是一样的,因此,我们可以利用测试代码来调试,既避免了自然驱动的缺点,又无需编写专门的调试驱动代码。
5.5 减少调试工作
    如果边编码边测试,则只有在测试失败时才可能需要调试,从而大量减少调试工作。另一方面,如果测试工具具有描述程序行为的功能,即显示输入输出数据,及标示出所执行的代码,那么,当程序有错时,浏览输入输出数据和所执行的代码,通常都能快速发现错误原因,从而进一步减少单步调试。
5.6 增强调试器功能
    如果测试代码由工具自动生成,可以考虑通过加强测试代码的功能,来间接增强调试器功能,具体来说,以下功能是很有价值的:
    自动选择调试输入:捕捉合适的调试输入通常需要高级断点,有时是很麻烦的,如果由测试工具来选择某个测试用例作为调试输入,那么又可以省下不少时间。
    后退和重复:调试器一般不支持后退,但测试代码可以实现模拟后退,例如,当到达测试代码尾部时,自动检查是否处于调试状态,如果是,则返回测试代码入口重新执行,这样,调试时只要使用"执行到光标所在行"之类的调试器命令,就可以实现后退和重复。由于每个测试用例都是独立的,重新执行时会建立新的被测试对象,因此,后退时,成员变量、参数、局部变量都能实现真正的后退,全局变量和静态变量则不能后退(幸运的是,这两种变量在一般函数中用得不多)。可以实现无限制的后退与重复,调试结束时关闭调试器就行了。
    调试过程中切换输入:有时,需要比较不同输入时某个变量的值,如果调试过程中可以切换输入,岂不妙哉?综合使用"自动选择调试输入"和"后退和重复"即可做到调试过程中切换输入。
    增强编辑继续功能:"编辑继续"功能对已执行过的代码通常是无效的,但利用后退和重复功能,可以让"编辑继续"功能发挥更大的效能。
5.7 实现零时间彻底测试
    第四章讨论了实现彻底测试的"三步法",本章讨论了减少测试时间及挖掘编码调试中的效率潜力,用减少编码调试时间来抵消测试时间,从而实现零时间彻底测试。
    单元测试和编码调试有紧密的关系,边编码边测试,实现编码调试测试一体化,可以避免重复工作,最大限度地提高开发效率。如果测试工具实现了本文所述的全部技术,那么,做到"零时间彻底测试"是不成问题的,甚至可以进一步缩减编码阶段的工期,我们来比较一下"测试耗费的时间"及"测试所节约的时间",看看哪一个更多些:
    假如测试工具实现了前述的所有技术和方法,那么:
    测试方面:
    第1步,自动生成测试代码,只要填写输入输出数据就可以建立第一个测试用例,其他用例采用拷贝修改的办法建立。这一步只需建立容易想到的用例,不必考虑是否遗漏,因此,这一步费时是很少的;
    第2步,由工具自动统计白盒覆盖率,自动画出逻辑结构图,并标示未覆盖的逻辑单位(语句、条件、分支、路径),用户选择一个未覆盖的逻辑单位,系统自动从现有的测试用例中计算出一个近似用例,并生成一些提示,依据提示修改一个输入数据及预期输出,即可建立预期的测试用例。如果逻辑单位不可覆盖,也能根据修改提示判断出来并从逻辑结构图中删除。这一步是"拾遗",即找出遗漏的测试用例,这种用例的数量通常不多,因此耗时也不会太多;
    第3步,由工具自动生成大量测试用例,捕捉编码时未考虑某些特殊输入形成的错误。这一步通常不需要消耗什么时间。
    再来看看能够从编码调试中挖出来的时间:
    通过描述程序行为,帮助整理和完善编码思路,提高编码效率;
    通过描述程序行为,帮助快速找出错误原因,减少单步调试;
    通过增强调试器功能,提高调试效率。
    编程工作的主要时间消耗在于编程思路和调试,上述功能从这两方面入手,所节约的时间是相当可观的,当编写比较复杂的代码时,所节约的时间可能比测试所消费的时间多得多,不但能实现零时间测试,而且还可能缩短编码工期。
5.8 舒适高效地开发
    在原来用于编码的时间内同时完成编码和单元测试,会不会增加程序员的劳动强度?不会的,因为:
    1. 找出并消除局部代码中的错误,本来就是程序员的份内工具,并不是额外工作;
    2. 编程最累人的是调试,翻江倒海地找一个小小Bug的经历,相信每个程序员都有过,不但累人,而且气人,充分的单元测试使这种情形几乎不会出现;
    3. 编程工作中比较累人的还有清理思路,一般环境下要了解程序行为并不容易,这对程序员完善编程思路很不利,如果通过测试工具来描述程序行为,使程序行为一目了然,不但提高工作效率,还会感觉比较舒适。
    程序员的工作目的是解决问题,工作乐趣也在于解决问题,最忌的是注意力被分散和思维被干扰。本文介绍的单元测试技术,测试工作的大部分由工具完成,不会分散注意力和干扰思维,并通过描述程序行为等手段,帮助程序员专注于问题的解决,更好地发挥潜能,也使工作更有乐趣。
结束语
    单元测试不但保证局部代码的质量,同时使开发过程自然而然地变得"敏捷"。单元测试对项目或产品的整个生命周期都具有积极的影响:
    对需求分析、设计的影响:自动回归测试可以发现代码修改所引入的错误,使开发过程可以适应频繁变化的需求,减轻需求分析和架构设计的压力,轻松实现螺旋式的开发过程。
    对后期测试的影响:由于代码错误已很少,大幅减少集成测试和系统测试的成本,自动回归测试也使修正错误的成本大量降低。
    对维护、升级的影响:高质量的产品大量降低维护费用,另一方面,升级相当于需求的增加或变化,自动回归测试也会产生重要的作用。
    实施或改进单元测试,是低投入、高效益的技术进步,将极大地提升软件企业和软件产品的竞争力。