面向对象软件测试包括面向对象分析测试(OOA Test)、面向对象设计测试(OOD Test)和面向对象编码测试(OOP Test)3个层次。面向对象系统测试是在类测试的基础上,对系统架构及类设计进行分析,验证系统的符合性及能力。从这个意义上,面向对象软件测试同面向过程软件测试并无本质差别,唯有类测试体现了面向对象软件测试的特点。
面向过程软件,逻辑驱动测试是对函数的测试;面向对象软件,逻辑驱动测试是对类的测试,即对基类和派生类的测试。类是面向对象软件的基本单元,类测试方法可以推广到类族测试。类封装了对象的属性和方法,因此对类的测试必然包含属性和方法的测试。方法的测试可以采用基于逻辑驱动的测试技术。但测试人员往往把类视为一个整体,将其属性和方法结合起来进行测试,即对类实现是否正确,类及其属性和方法设计是否合理,是否存在多余的类及其属性和方法,是否缺少相应的类及其属性和方法以及不同类之间的关联进行测试验证。状态测试是类测试的重要环节,基于状态的类测试是一种行之有效的测试方法。一般地,类测试过程为:为类创建实例→构建适当的环境→运行测试用例(向一个实例发送一个或多个消息)→通过参数检测测试执行结果→对测试结果进行分析评价和总结。
统一建模语言(Unified Modeling Language,UML)以面向对象图的方式描述对象及其他对象之间的关系,支持应用系统开发,是一种支持对象技术的可视化建模语言。UML是一种用于说明、可视化、构建和编写一个正在开发、面向对象、软件密集系统的开发方法,对大规模复杂系统建模,特别是对架构层次的测试验证,具有显著效果。UML系统开发包括功能模型、对象模型、动态模型3个模型。类的UML表示如图5-37所示。
图5-37 类的UML表示
UML中,用来表示类的符号是矩形,并划分为名称区域(显示类的名称)、属性区域(显示类中定义的变量)和操作区域(显示类中定义的方法)3个区域。类之间包括关联、泛化、实现、依赖、聚合和组合6种关系。类的每种关系分别使用不同符号表示,并分别用私有、保护和公有3个关键字来修饰。
类测试旨在找出实际运行的状态模型与预期模型之间的差异。需求规格产生的状态模型称为Spec(Specification),系统实际运行状态模型称为IUT(Implementation Under Test)。将IUT和Spec进行比较,通常会存在如下差异。
(1) 丢失转换: 对一个有效激发事件,IUT未做出响应。
(2) 不正确的转换: IUT行为到达一个不正确的结果状态。
(3) 丢失动作: 对于一个转换,IUT没有产生任何动作,输出遗漏。
(4) 不正确的动作: 对于一个转换,IUT产生错误动作,输出错误。
(5) 讹误状态: IUT通过转换,到达无效状态。
当类中存在错误时,一个类可能对消息序列做出反应,产生非预期或错误。
(1) 潜行路径: IUT接收了一个非法或在此状态中未被规定的事件,到达有效状态。
(2) 非法消息失败: IUT未正确处理一个非法消息,接收该消息之后到达一个无效状态。
(3) 陷阱门: IUT接收了一个在规约中未定义的事件。
5.7.3.1 类测试用例设计
基于状态、限制及代码覆盖率,进行类测试用例设计。根据状态转换确定测试用例。
5.7.3.2 类测试驱动设计
基于开发者视角,测试驱动就是在测试设计之前设计的测试代码;基于测试者视角,类测试驱动是为了执行测试,运行测试用例,检出软件错误。测试驱动构建应简单、透明且易于维护,能够提供尽可能多的服务,同时兼顾自增量更新。理想的情况是能够重用已有测试驱动程序。类测试驱动程序开发方法很多,下面以Java语言为例说明测试驱动程序的结构。
(1)在main方法中写入需要运行的测试用例,实现main方法,编译并执行该类。
(2)在类中实现一个静态测试方法,通过调用该方法收集每个测试用例的执行结果。
(3)实现独立的测试类,执行并收集每个测试用例的执行结果。
5.7.3.3 类测试序列生成
用例树构造需要以一定的测试覆盖准则为基础,在状态机模型中,存在以下两种覆盖准则。
(1) 状态覆盖: 状态图中,每一个状态至少被访问一次。
(2) 迁移覆盖: 状态图中,每一个使能迁移至少被激活一次。
迁移覆盖集包含状态覆盖集,迁移覆盖强于状态覆盖。测试目标至少包括迁移覆盖。一种测试用例生成方法是基于状态机模型,将状态图视为一种流程图,其具有一个起始点和一个终节点,然后就像基于流程图的测试一样寻找由起始点到终节点的基本路径集,实现状态覆盖。但状态图上的每一个状态都不同于流程图上的语句节点,这样的测试生成法不能满足基于状态测试的要求。另一种是专门针对状态机模型的测试用例生成方法,其中最经典的当属T.S.Chow于1978年提出的 W 方法。
W 方法覆盖错误的能力较强,但所生成的测试序列集往往较大,尤其是当被测系统比较庞大时,适用性将显著下降。于是,作者提出了 改进方法,以各个状态的识别集 来代替系统的特征集 ,测试用例生成步骤为:
(1) 。其中, , 。
(2) 。 , 是 的识别集, 由 达到。
UIO(Userspace I/O)方法为每个状态规定一个唯一的输入/输出序列,代替该状态的识别集,生成测试用例。假设 是 的UIO序列中的输入序列,采用UIO方法产生的测试用例集为
(5-7)
由 达到。
用其所生成的测试用例,执行测试,检查模型中每个状态是否能够由初态正确地到达,然后检查模型中每一个转换及输出是否正确。
这几种方法的不同之处在于: W 方法采用整个状态机模型的区分集,测试时将区分集中的全部输入发送给拟检查的每一个状态,基于输出判断IUT是否达到具有此识别集的状态,当IUT中的其他状态或出现的谬误状态也错误地呈现了这一状态识别集的状态时, W 方法也可以将其检查出来。 方法同 W 方法类似,但它仅用各个状态的识别集来判断各个状态,仅为每一状态规定唯一的输入/输出序列,减少测试用例的生成,只能判断某一转换是否达到规定的状态,但当其他状态也错误地包含这个状态的UIO特性时,却无能为力,但仍不失为一种实用方法。图5-38展示了一个随机状态机模型。
图5-38 一个随机状态机模型
此状态机模型符合UIO方法的前提条件,可以采用UIO方法为其生成测试用例。
各状态选定的UIO分别为: , , 。则该状态机模型的状态覆盖集为
迁移覆盖集为
由此,生成测试序列集
相应的预期输出为
如前所述,UIO方法只能判断某一转换是否达到具有此UIO特性的状态。
假设图5-39是图5-38所示状态机模型的被测实现IUT,则IUT的 状态与 状态呈现同样的UIO特性,都包含序列 。在这种情况下,前述测试用例集无法检出这一错误。
图5-39 一个状态机模型(IUT)
针对上述问题,业界提出了一种 方法:虽然Spec中各个UIO对于每一个状态都是唯一的,但是在IUT中,这种唯一性未必成立。在对状态进行检查时,不仅需要检查该状态是否呈现了规定的UIO特性,还需要检查此状态是否也错误地呈现了其他状态的UIO特性。
这里,在为图5-38所示状态机模型生成UIO测试序列集的基础上,进一步生成 方法所需的额外测试序列,对 , ,预期输出为 。同样,对于 和 ,分别得到
将这些测试序列与上述UIO的测试序列合并,去掉重复序列,生成测试序列
预期输出为
采用这样的测试序列可以检出图5-35中IUT所存在的错误。 方法覆盖错误的能力要大于UIO方法。但是, 产生的测试序列太多,实际工程上大多仍然采用UIO方法。
IUT可能接收非法消息,因此必须生成包含非法消息的测试用例,参考潜行路径测试用例,在所生成测试序列集的基础上,进一步生成关于非法消息的测试序列集。
测试序列集生成方法是:首先使得系统达到某一状态 ,向该状态发送输入集中对于该状态是非法的消息,然后检查系统是否仍然停留在该状态。仍然假设 , 由 达到, 是 的输入集,而 是 的UIO输入序列,当 时,非法消息的测试序列生成的公式为
(5-8)
式中,各个状态非法消息集为
因为 , 各个状态的UIO仍然如前所述,测试序列集为
关于由非法消息序列产生的预期输出集,情形要相对复杂一些。当一个状态接收到一个非法消息时,正确的实现应该仍然停留在原状态,不产生任何输出或产生规约中所规定的异常输出。假设是由非法消息序列产生预期输出集,相应的预期输出为(用“-”代替无输出)
5.7.4.1 域测试法与随机测试数据生成
域测试法是一种常见的测试用例生成方法,其基本思想是程序中的每一条路径都与程序输入域的某一子域相对应。域测试法简单易行,但该方法未考虑状态模型的结构,所生成的测试数据难以与一定的测试序列匹配。
随机测试是指在程序的整个输入域上,随机选择数据作为测试用例,可以看作是域测试的一种特殊情况,被认为是一种退化的域测试,常被用于评价其他测试方法的优劣。这两种方法都是可供选择的测试数据生成方案。
5.7.4.2 基于遗传算法的测试数据生成
在输入域内随机产生输入数据,向被测类发送消息序列,判断输入数据是否满足要求,若不满足要求,则对输入数据进行遗传操作,产生新一代输入数据,重复此过程,直到产生满足要求的测试数据。基于遗传算法的测试数据生成过程如图5-40所示。
图5-40 基于遗传算法的测试数据生成过程
(1) 初始种群生成(编码) 。二进制编码(包括带符号的二进制编码)是常用编码方式,但这种方式对于浮点数或其他较复杂的数据类型,适用性有限。
(2) 评价函数构造或选取 。选取评价函数,以确定个体位串的适应度,建立遗传算法与实际问题的接口,如果没有或不存在评价函数,则遗传算法的搜索过程是盲目的,需要根据需求构造评价函数。
(3) 选择亲本 。如现有种群中没有符合要求的个体,选择亲本时,每次从现有种群中选择两个个体,作为新个体的双亲,再生成新一代种群。
(4) 遗传算子 。选择亲本之后,采用交叉算子、变异算子等遗传算子产生新个体。
(5) 生成新一代种群 。在亲本与其后代中按一定方法选择一定数量的个体,生成新一代种群。
5.7.5.1 继承层次结构中类的测试
父类被测试用例覆盖的代码被子类继承,只要父类代码未被子类覆盖,则无须重新创建测试用例。假设Class_A类有两个实例方法operation1()和operation2(),Class_B继承Class_A类并实现新的实例方法operation3(),Class_C继承Class_B且覆盖Class_B类的实例方法operation3()和operation2()。这3个类之间的继承关系如图5-41所示。
图5-41 类之间的继承关系
基于3个类的区别,确定继承测试用例中是否需要新的子类测试用例,如表5-24所示。
表5-24 类测试用例选择表
类测试用例增补原则为:如子类新增一个或多个新的操作,相应地增加测试用例;如子类定义的同名方法覆盖父类的方法,增加相应测试用例。那么,可以采用如图5-42所示接口构建类测试用例。对于基类,需要进行全部测试,当然,底层测试类可以对父类测试方法回归。
5.7.5.2 接口类测试
接口一定会在某个类中实现,可以使用实现接口的类来完成测试。其原则是: 如果接口未被任何类实现,则无须进行测试;如果接口已被其他类实现,则必须针对实现该接口的类进行测试 。为接口设计一个通用测试程序,引入一个抽象测试类,其方法用于测试接口的共同行为,然后创建接口对象,设计测试程序。该测试程序不仅能够测试当前已实现类的通用属性,还可以不加修改地应用于将来实现的类。InterFace接口测试类如图5-43所示。
图5-42 接口构建类测试用例
图5-43 InterFace接口测试类
(1)确定待测试类,对测试准入条件进行检查和确认,进行测试策划和测试准备。
(2)创建一个抽象测试类,声明拟验证功能的测试方法,在具体的测试程序实现中继承该测试类,并修改相应的测试方法。
(3)在每个接口实现中,运行该测试程序,验证接口行为。
(4)确定创建接口对象的代码,将该代码改为具体且已实现类的创建方法,将该对象声明为接口对象,重复这一过程,直到测试程序中没有已经实现类的对象。
(5)声明需要在测试中调用的抽象方法。
(6)测试只涉及接口及其抽象的测试方法,将测试程序移入抽象的测试类。
(7)重复这一过程直到所有测试都移入抽象的测试类。
(8)重复上述过程,除具体实现的特有方法的测试程序外,所有测试代码均已完成。
基于上述步骤,以java.util.Iterator接口测试为例进行分析和说明。如下代码ListIteratorTest是测试java.util.ListIterator接口的一个具体实现。
引入抽象测试类IteratorTest,将ListIteratorTest类的实现添加到IteratorTest,得到如下结果。
显然,只要实现了makeNoMoreElementsIterator()方法,即可将所有的测试移入IteratorTest类中。也就是说,只需将如下方法封装到ListIteratorTest类中即可。
ListIteratorTest继承抽象类IteratorTest,实现的创建方法返回一个iterator而不是一个空列表。类似地,如果测试一个基于Set类的iterator,应创建一个继承IteratorTest的SetIteratorTest类,这个类的makeNoMoreElementsIterator()方法也应返回相应的iterator而不是一个空的Set对象。
该抽象测试用例能否正常工作,取决于Junit中的测试等级。一个TestCase类在继承其父类时继承其父类的所有测试。上述描述中的ListIteratorTest继承IteratorTest,所以只要在测试执行过程中运行ListIteratorTest,IteratorTest中的测试都将得以运行。
下面以形状类周长和面积计算为例,对面向对象软件测试的流程及方法进行分析说明。
5.7.6.1 类及关系确定
定义一个形状类Shape,派生出矩形Rectangle、正方形Square和圆Circle共3种形状类,利用多态性及虚函数形式,实现周长和面积计算。其中Shape类为基类,表示形状;Rectangle类和Circle类均继承自Shape类,分别表示矩形和圆形;Square类继承自Rectangle类,表示正方形。形状类的周长及面积计算实现代码如下。
抽象方法只提供一个方法名,不包含任何实现代码,需要在子类中重写。包含抽象方法的类称为抽象类。Shape类为抽象类,定义了两个抽象方法perimeter()和area(),分别用来计算周长和面积。矩形类的周长及面积计算代码如下。
Rectangle类定义了两个私有成员变量,分别表示矩形的长和宽,重写覆盖了父类Shape求周长和面积的方法。Square类定义了一个私有成员变量表示边长,重写覆盖了父类Rectangle求周长和面积的方法。Circle类定义了一个私有成员变量表示半径,重写覆盖了父类Shape求周长和面积的方法。正方形类和圆形类的周长及面积计算实现代码分别如下。
5.7.6.2 类优先级划分
若资源有限,可以不对私有类进行测试,但必须对上层关键类进行测试。将所有类按复杂程度、使用频率、风险等级设定优先级。一个基类,如被其他类继承或调用,则优先测试这种类。假设某系统包含4个类,其中类B和类C继承自类A,类D和类A是平行关系,类D调用类A的属性或方法。显然,应优先安排类A的测试。
对于前面所讨论的形状类,Shape类是私有类的基类,被其他类所继承,应优先测试。但Shape类是一个抽象类,其方法没有具体的实现细节,只需检查Shape类的成员变量和方法设计是否合理即可。因此,测试重点为Rectangle类和Circle类。
5.7.6.3 类静态分析
(1)检查类的结构是否合理。
(2)检查public、private、protect等关键字设置是否合理,一般成员变量设置为私有private,不允许其他类访问,构造函数设置为public。
(3)检查类中的成员变量和方法设置是否合理,是否存在缺少或多余的情况。
(4)检查是否符合相应的编码规范。
对于Shape类,不同平面图形的周长、面积计算所需参数不一样,矩形用到两边,圆形用到半径,无法统一定义。Shape类仅定义了两个抽象方法,没有成员变量,周长和面积计算过程中涉及小数,Shape类及两个抽象方法均定义为public,两个抽象方法的返回值均定义为double型,同样也是合理的。此外,Shape类符合Java编程规范,无语法错误。
5.7.6.4 类测试用例设计
上述形状类中,计算参数均通过构造函数获得,仅需修改构造函数参数即可。例如,Rectangle类测试时,可以构造测试用例(1,1)、(1.2,3.4)、(a,b)、(,)等。
5.7.6.5 测试驱动程序构造
测试驱动程序即测试驱动框架,用于动态测试。Main函数就是一种测试驱动程序,比如要测试Rectangle类,在该类中添加如下代码重新编辑执行即可。
输入“Javac Rectangle.Java”,Java编译器报错。那么,边长为“空”和边长为“a、b”的测试结果如图5-44所示。
图5-44 Rectangle类的测试结果
Java编译器具有较强的容错能力,能自动检查参数类型是否匹配,(1.2,3.4)、(a,b)、(,)这3组用例无法通过编译。但是,这并不能算作错误,而是Java编译器对异常的处理。