首页 » C语言解惑 » C语言解惑全文在线阅读

《C语言解惑》13.8 软件测试

关灯直达底部

正确性是程序最重要的属性。即使是很小的程序,要想对它采用严格的数学证明方法来证明其正确性,也是非常困难的,因此只好求助于程序测试过程来实施这项工作。程序测试是指在目标计算机上利用输入数据(也称为测试数据)运行该程序,将其运行结果与所期望的结果进行比较。如果两种结果不同,就可判定程序存在问题。然而不幸的是,即使两种结果相同,也不能够断定程序就是正确的,因为对于其他的测试数据,可能会得到不同的结果。如果使用了许多组测试数据都能得到相同的结果,则可增加对程序正确性的信心。不过,要想通过使用所有可能的测试数据来验证程序是否正确,对于大多数实际的程序,可能的测试数据的数量就不知有多大。显然不可能进行穷尽测试,因此实际用来测试的输入数据只能是整个输入数据空间的子集,称之为测试集。例如关于变量x的二次函数,其标准形式为


ax2+bx+c=0  

其中a,b,c的值是已知的,且a≠0。

【例13.14】下面的程序计算并输出该二次方程的根。


#include <stdio.h>#include <math.h>void FindRoots(double a, double b, double c){  //计算并输出一个二次方程的根       double d=b*b-4*a*c;                    //1       if(d>0) {                              //2  两个实数根            double sqrtd=sqrt(d);            printf(/"有如下两个实数根:n/");            printf(/"%f和%fn/",(-b+sqrtd)/(2*a),(-b-sqrtd)/(2*a));       }                                   //6       else if (d==0)                         //7  两个根相同           printf(/"只有一个实数根:%fn/",(-b/(2*a)));     //8       else {                              //9  复数根           printf(/"有如下两个复数根:n/");           printf(/"%f+%fin/",-b/(2*a),sqrt(-d)/(2*a));           printf(/"%f-%fin/",-b/(2*a),sqrt(-d)/(2*a));       }                                   //13}  

程序运行的结果应是:当d=0时,所得到的两个根是一样的;当d>0时,两个根不同且是实数;当d<0时,两个根也不相同且为复数。

现在不去试图对该函数的正确性进行形式化证明,而是希望通过测试来验证其正确性。不难知道,对于该程序来说,所有可能的输入数据的数目实际上就是所有不同的输入数据(a,b,c)的数目,其中a≠0。即使a,b和c都被限制为整数,所有可能的测试数目也是非常巨大的,因此要想测试所有的输入数据是不可能的。若整数的长度为16位,b和c有2 16种不同取值,a有2 16-1种不同取值(因为a不能为0)。所有不同测试组的数目将达到2 32*(2 16-1)。如果目标计算机能按每秒钟1000 000个测试数据的速率进行测试,至少也需要9年才能完成!所以实际使用的测试集仅是整个测试数据空间的一个子集。

由于可以提供给一个程序的不同输入数据的数目一般都非常巨大,所以测试通常都被限制在一个很小的子集中进行。使用子集所完成的测试不能完全保证程序的正确性,所以测试的目的不是去建立正确性认证,而是要暴露程序中的错误!必须选择能暴露程序中所存在错误的测试数据,不同的测试数据可以暴露程序中不同的错误。

如果使用数据(a,b,c)=(1,-5,6)来进行测试,程序将输出2和3。程序的行为与期望的行为是一致的,因此可以推断对于该输入数据,程序是正确的。然而,使用一个适当的测试数据子集来验证所观察行为与所期望行为的一致性,并不能证明对于所有的输入数据程序都能够正确工作。一段错误的代码也可能给出正确的结果,例如:如果在关于表达式


d=b*b-4*a*c  

中忽略a,将其错误地写成


d=b*b-4*c;  

在使用数据(a,b,c)=(1,-5,6)来进行测试时,d的值及所测试的结果仍与原来正确的结果相同,这是因为a=1。但是实际上由于使用测试数据(1,-5,6)而未能执行完代码中的所有语句,即第6条以后的语句没执行,因此对这些语句正确性还没有多大的把握。假设使用下面的测试数据:


void main(){     FindRoots(1,-5,6);       FindRoots(1,3,2);     FindRoots(2,5,2);     FindRoots(1,-8,16);     FindRoots(1,2,5);}  

测试程序输出如下:


有如下两个实数根:3.000000和2.000000有如下两个实数根:-1.000000和-2.000000有如下两个实数根:-0.500000和-2.000000只有一个实数根:4.000000有如下两个复数根:-1.000000+2.000000i-1.000000-2.000000i 

由此可见,因为测试集{(1,-5,6),(1,3,2),(2,5,2)}的每个测试数据仅需执行代码的前6行语句,所以这个测试集仅可用来暴露FindRoots前6行语句中存在的错误。测试集{(1,-8,16),(1,2,5)}执行第2条和第7条的判断语句,只有测试集{(1,2,5)}才执行全部判断语句,所以该测试集将可以暴露较多的错误。

可以断定,不可能对一个软件进行彻底的测试。问题是要选择合适的测试技术,通过执行有限个测试用例,尽可能多的发现软件错误。

归纳起来,软件测试的目标如下:

(1)测试是一个以找出错误为目的的执行软件的处理过程。

(2)好的测试用例必须有很高的发现错误的概率。

(3)成功的测试是一种能暴露出尚未发现的错误的测试。

因此,决不能把一个成功的测试当作不发现错误的测试。恰恰相反,它应当是一种可以系统地暴露出各种不同类型错误的测试。只有如此,才能对软件质量做出明确的保证。为实现这个目标,应对软件实施一系列的测试步骤。每个测试步骤通过采用一系列系统的测试技术,有效地选择测试用例来完成。除了人工测试技术以外,目前出现了越来越多的自动测试工具,以辅助进行软件开发过程中最为困难、代价高昂的测试工作。

一般来讲,可以把软件测试分为模块测试、组装测试和确认测试。

13.8.1 模块测试

因为模块测试是实现阶段最为重要的一个软件工程步骤,是软件质量保证的关键环节,即使经过代码评审,模块中必然要留存许多未被发现的逻辑错误,必须通过测试来暴露。这其实也是在程序组装成一个整体之前,分别测试各个模块的操作。其优点如下:

(1)可以全面测试各个函数。当完成了对某个函数的测试之后,就可以确信它能够正确地工作。

(2)测试容易控制。若某个函数的测试失败,可立即把问题定在该函数之内。对于通常的测试方法,最大的问题就是当发现程序不能正常工作时,又找不出程序的错误究竟在哪里。

(3)测试数据容易构造。可以测试函数而不必把测试数据放入数据文件之中。测试数据的构造对通常的测试方法是一个很棘手的问题,其结果常常造成测试不充分。

在测试之前,必须理解究竟要证明什么,测试什么和怎样测试。测试应该按源文件分组,因为在每个源文件中的函数不能分开,所以把它们放在一起测试。对每个被测试的源文件,都要列出测试程序清单,同时给出测试运行结果。

程序功能描述、伪码程序和源文件清单,对于决定测试什么和怎样测试,将有很大的帮助。一般可以使用特殊的函数测试main()函数,即设计一个虚构的执行过程,替代实际的函数体。它们包括前面叙述过的printf()语句,告知它们何时被调用和显示所接收的参数,但返回值将从键盘上获得。这样的特殊函数常常称为桩(stub)函数。

按照软件工程的观点,模块测试应以详细设计描述为指导,按照测试计划来进行。

模块测试主要评价模块的5个特性:

(1)模块接口;

(2)模块内部数据结构;

(3)重要的执行路径;

(4)错误处理路径;

(5)影响上述几点的边缘条件。

在开始其他任何测试之前,必须首先测试贯穿模块入口和出口的数据流。如果数据不能正确地进入和退出模块,其他各项测试便无从下手。接口测试内容包括:

(1)输入参数的数目、次序和属性是否与变元相一致;

(2)输出的变元数目、次序和属性是否与调用该模块的参数相一致;

(3)调用的内部函数的参数的数目、次序和属性是否正确;

(4)对参数的任何访问是否与调用模块无关;

(5)输入是否只改变变元;

(6)全部变量定义是否相容。

在模块实现外部I/O时,还需附加以下的接口测试:

(1)文件属性是否正确;

(2)OPEN语句是否正确;

(3)I/O语句与格式说明是否匹配;

(4)记录长度与缓存区的大小是否匹配;

(5)文件是否在处理之前打开,并在处理之后关闭;

(6)是否处理了文件结束条件;

(7)是否处理了输入输出错误。

对于模块中的局部数据结构应当测试:

(1)不正确的或不相容的数据说明;

(2)置初值错误或错误的默认值;

(3)错误的变量名;

(4)不相容的数据类型;

(5)下溢、上溢或地址错;

在模块测试期间,对关键的软件路径的测试是一项重要工作。测试用例应当能测试出不正确的计算、错误的比较或者控制流向不正确而引起的各种错误。这些错误包括:

(1)算术优先级不正确或理解错误;

(2)运算方式混淆;

(3)置初值错误;

(4)精度不够;

(5)表达式的操作符的使用错误;

(6)不同的数据类型比较;

(7)逻辑运算符或优先级不正确;

(8)循环异常终止或死循环;

(9)出口错误;

(10)循环变量修改不正确。

一个好的软件必须自身能够预见错误条件,并且当错误出现时,能通过错误处理路径提示用户改正错误并重新处理,或者有效地结束处理。因此,测试用例的设计应包括足够的错误条件,以揭示在这方面可能出现的潜在错误。这包括:

(1)出错说明是否可理解;

(2)指示的错误是否对应实际遇到的错误;

(3)错误条件是否在错误处理之前已经引起系统干预;

(4)提供的出错说明是否足以帮助确定出错位置。

边缘测试是模块测试的最后一个步骤。软件经常在边缘下出错。例如,在循环处理一维数组的n个元素时,在循环的第一次和第n次往往发生错误。因此采用刚刚小于或恰好等于最大值,刚刚小于或恰好等于最小值的测试数据往往能揭示出数据结构、控制流和数值结果方面的许多错误。

如果时间因素是模块的重要特征,那么还要专门进行关键路径测试,以便确定最坏情况及平均意义下影响模块执行时间的因素。

图13-13给出测试与其他阶段的关系,由此可见,错误应及早发现和解决,否则解决后期的错误将花费更大的代价。

图13-13 测试与其他阶段的关系

13.8.2 组装测试

组装测试是软件生存周期中的一个独立阶段。其主要任务是按照选定的策略,采用系统化的方法,将经过模块测试的模块按预先制定的计划逐步进行组装和测试。这种测试的目的在于发现与模块接口有关的问题,并将各个模块构成一个设计所要求的软件系统。

为了完成上述任务,组装测试应按以下步骤进行。

(1)执行测试计划中说明的所有系统组装测试;

(2)改正测试中暴露出来的错误;

(3)分析测试结果;

(4)书写测试分析报告;

(5)组织人员严格评审,直至通过为止。

另外,还应同时完成可运行的系统源程序清单和组装测试分析报告。

13.8.3 确认测试

测试的最后一个步骤也是软件开发的最后一个阶段,是验证所组合的软件系统是否确实满足用户的需要。这是软件开发部门把软件产品交付使用之前的最后一项测试,称为确认测试。因此,这个测试步骤所发现的错误往往是“软件需求规范书”中的错误。