1.3 基于面向对象的平台化设计思想
面向对象的设计思想在现代软件设计中已经不是一个新鲜事了,但是就笔者看来,很多测试团队虽然使用了面向对象的语言,却依旧停留在面向过程的思想中。当然笔者不是说,面向对象一定比面向过程优越,而是面向对象本身的一些特点可以帮助我们实现一些强大的功能,这一节我们讨论一下基于面向对象的平台设计思想。
1.3.1 面向对象设计思想
如果你对面向对象已经很熟悉,那么可以略过这一节。但是作为一个测试工程师,也许没有太多的机会接触面向对象的思想,虽然现在的Python是基于面向对象的语言,并且已经被广泛地使用,但是很多测试出身的工程师依然按照传统的面向过程的思想来写脚本。在这里,我们利用面向对象的一些特性,来更有效率地开发测试平台。
简单地说,面向对象是一种软件设计方法,其最主要的特点是从“现实世界”的角度,用分类的方法进行分解和抽象,并通过继承、包含等关系来构造一个系统。
接下来就简单地介绍一下面向对象设计思想的一些基本概念,读者感兴趣的话,可以通过一些专门讲面向对象程序设计的书籍做更进一步的学习。
1.3.1.1 封装
在面向对象的设计方法中,对象是最基本的元素。对象可以是对具体事物、抽象事物的描述,简单到一个字节,复杂到一枚火箭,都可以用对象来描述。对象都有其特定的状态和行为,比如把一个手机看成一个对象,那么手机的屏幕大小、长度、高度、宽度、电池容量等,都是对其状态的描述。手机的开机、关机、解锁、拨号等,都是手机这个对象所包含的行为。
如果我们把一组拥有相同状态的行为进行抽象,就产生了“类”的概念。也就是说,对象可以抽象成类,而类实例化以后,就是对象所对应的实例。比如“手机”就是一个类,具体到我在使用的手机,就是这个类的实例,而在看本书的读者的手机,则是另一个手机的实例。这也引出了面向对象的一个特点——封装,对象只会暴露想要暴露的属性和方法以供给外界调用,而将其中的逻辑封装在对象的内部。
下面我们以Python代码为例,定义一个手机类MobilePhone,来解释类和对象的关系及封装行为。
上述代码中,手机类MobilePhone用来描述一个现实世界的手机,其中包含了一些简单的属性,比如电话号码number,电量battery_percentage,以及一些简单的方法——拨打电话、发送消息、充电等。当然,这只是一个简单的例子,目的是说明面向对象的一个基本封装思路,这段代码的运行结果只是简单地在屏幕上打印一些信息,不过却很直观地说明了面向对象的封装、实例化过程,以及实例之间相互的联系。
运行结果:
回到自动化测试开发中,我们就可以利用面向对象的封装方法来描述日常测试中的被测试对象、测试工具、测试报告、测试结果、测试参数等。
1.3.1.2 继承
继承是面向对象程序设计的另一个特点,它不仅符合人们对现实世界的抽象规则,而且能够大大简化类的创建工作,增加代码的复用性。继续我们上一节的例子,我们定义了手机这个类,但是这个类的范围实在太大了,不同品牌的手机之间,可能会有不同的特性。比如,功能手机就只能打电话听音乐,而智能手机不仅可以打电话,还可以上网冲浪,安装各种App。如果我们希望用类来描述手机,就可以建立一个层次化的继承关系,如图1-3所示。
图1-3 手机分类
按照图1-3的继承逻辑,手机是一个最基本的类,封装了打电话、发短信等基本方法。功能手机和智能手机均继承于手机类。对于功能手机,可以添加额外的属性,比如操作系统的类别,而对于智能手机来讲,还可以根据屏幕的类型继续继承,比如全面屏手机和折叠屏手机。
由图1-3可以看到,越是往下继承,类所描述的手机类型就越具体,包含的属性和方法也越多,子类会有越来越多的父类所不支持的方法。接下来,看一段有关继承的代码:
以上代码基于1.3.1.1中定义的继承。
但是继承的分类方法并不是只有一种,需要根据具体的情况来决定。还是以手机为例,除了用功能手机和智能手机来作为第一层继承的分类,还可以使用3G、4G、5G来分类,每一类又可以分为大屏手机、全键盘手机等。手机按照网络进行分类,如图1-4所示。
图1-4 手机按照网络进行分类
因此,如何利用继承来分类,增加代码的重用性,是利用面向对象设计测试平台的一个很重要的问题。
1.3.1.3 多态
多态是面向对象设计方法的精髓所在。所谓多态是指一个对象具有多种形态,比如子类在继承父类之后,可以对父类的方法进行重写。在具体的实例化过程中,根据实例化的类型来决定调用方法的版本。看上去比较难理解,我们继续使用手机作为例子来加以说明。
假设iPhone是在iPhone5S出现之前所定义的类,它包含一个解锁方法Unlock,只需要输入密码就可以解锁。当iPhone5S问世后,我们需要用iPhone5S这个类来描述它,当然它依旧包含iPhone的一切功能,所以将iPhone5S类继承原来的IPhone类。但是我们发现,由于解锁功能增加了指纹,所以解锁的过程发生了变化:首先会匹配指纹,如果指纹匹配失败,再输入密码进行解锁,所以我们按照这个解锁的逻辑重写iPhone5S的解锁方法。然后,iPhoneX出现了,于是我们继续在iPhone类的基础上继承iPhoneX,并且继续对Unlock进行重写:首先判断face_id是否通过验证,如果验证失败则输入密码。
可以看到,通过这样的继承,以及对Unlock方法的重写——在面向对象中的专业术语叫作重载,我们就会产生3个不同版本的Unlock方法,它们分别属于iPhone、iPhone5S、iPhoneX。那么在实际开发过程中如何区分调用的是哪个版本呢?最简单的区分方法就是,使用哪个类进行实例化,就调用哪个类的Unlock版本。下面来看一下具体的实现代码:
这种多态在面向对象的设计中非常有用,可以让用户自由地通过继承来实现子类自己的方法,而不需要改变原来的调用逻辑。
1.3.1.4 抽象
在面向对象的设计思想中,有两种抽象的概念会被一起讨论,那就是抽象类和接口。
抽象类是一种特殊的类,它本身不能被实例化,并且包含抽象方法。抽象方法是一种特殊的方法,它只有定义,没有具体的实现过程。抽象类能够预先提供一些对象抽象的定义,在实现部分具体的方法后,让其子类去实现抽象方法。
还是以手机为例,我们把手机作为一个抽象类,重新定义1.3.1.1节中的示例所定义的手机类。这里要注意的是,Python3支持抽象类,而在Python2.7中则需要引入第三方的包来实现抽象类的功能。
假设我们有一个测试方法,对手机的解锁功能进行测试。传入该测试方法的参数是抽象类MobilePhone,那么如果想测试一款新的手机,就只需要继承MobilePhone抽象类,并实现相应的方法,不需要对该测试方法进行任何修改,示例代码如下:
在test_unlock方法中,为了增加代码的健壮性,我们使用isinstance方法来判断传入的参数是否是MobilePhone类的实例,这样可以保证传入该测试方法的参数都是正确的类型。
接口是一种更特殊的抽象类,主要是对具有相同属性或方法的事物的封装,不包含任何实现,也没有构造函数。
这里举一个强类型语言C#中的接口定义的例子,来帮助读者更好地理解接口的概念。
除手机外,平板电脑、笔记本和PC都有解锁方法,但它们并不是一类事物。我们可以以解锁为共同的方法来定义一个接口,称为可解锁的IUnlockable接口,并且这个接口中包含一个方法Unlock。这个方法没有任何具体的实现。当我们定义手机类、平板电脑类、笔记本类和PC类的时候,就需要实现这个接口,并且添加具体的实现过程,具体实现代码如下:
在具体的使用过程中,我们通过将设备对象实例化成接口类型,并分别调用不同版本的接口方法来实现多态。比如,我们创建一个IUnlockable的集合,就可以往里面添加不同类型的实例,比如手机、平板电脑等。只要是实现了IUnlockable接口的类的实例,都可以被添加到这个集合中,再通过一个简单的迭代语句来执行集合中所有实例的Unlock方法。
接口和抽象类有时候可以互换,在使用上也有相似之处。但是接口是为了解决某些强类型语言中不支持多继承而出现的解决方案,一个类只能继承一个父类,但是可以实现多个接口。而从使用上看,接口往往用来表示某种特性,所以接口的命名通常是以able结尾的。
Python并没有接口的概念,但是可以利用接口的一些概念,因为Python不是强类型的,也没有编译检查,任何传递的对象均可以调用其方法——前提条件是,该方法存在。不严谨地说,Python本身就支持接口的设计方法。
1.3.2 模块化设计
模块化设计是一种拆分的设计思想,将一个产品拆分为很小的功能,根据需求将一些功能要素组合在一起,形成一个相对独立的子系统,并通过特定的标准接口与外界通信。这样就可以将不同功能的子系统进行不同形式的组装,形成不同功能,或者相同功能不同性能的系统。
这种设计方法能够极大地降低产品的成本和新产品开发的周期,在各行各业都已经被证明是可行的设计方案。比如宝马汽车的发动机,就将单个汽缸模块化,一个汽缸的排量是0.5升,这样只需要组装不同数量的汽缸,就可以生产出不同排量的发动机,3个汽缸就是1.5升排量的发动机,4个汽缸就是2.0升排量的发动机,6个汽缸就是3.0升排量的发动机。相较于原来的一种排量的发动机就需要一条单独的生产线的模式,这种模块化的发动机方案极大地降低了生产成本。
我们知道,目前软件产品的规模越来越大,从单机运行的程序,向分布式网络化,甚至云上部署发展。迭代速度也越来越快,新功能的发布可能以周来计算,甚至有些问题需要立即修复。所以对自动化测试平台来说,通过模块化来提高测试系统的快速扩展能力和部署能力是一种很好的思路。
那么在软件自动化测试系统中,模块化能为我们带来什么样的优势呢?我们先来看看一些相对比较原始的自动化测试平台的方案。假设一个测试团队为一个网络设备的项目建立了一个自动化测试平台,通过控制外部的测试仪表进行数据报文和流量的发送,并以此来验证网络设备的功能和性能是否符合设计,最终,将测试结果上传到数据库。这个平台的设计如图1-5所示。
图1-5 测试用例与测试仪表耦合的测试平台
如果我们对一些功能进行封装,比如封装调用测试仪表的库的功能,那么当仪表调用遇到问题时,可以单独调试仪表操作的库。如果另一个团队正好也在测试类似的产品,但其使用的是不同公司生产的仪表,用的数据库也是不同类型的,那么复用这个平台的测试用例几乎是不可能的。
如果将这些库进一步封装,通过模块化的思路提供统一的抽象接口,那么这些库就能成为一个特定的模块,我们可以在测试用例中通过调用模块提供的标准接口来调用模块的功能,这样只要开发符合接口定义的模块,以及做简单的替换,就可以实现不同的功能。测试用例与仪表操作解耦后的平台如图1-6所示。
图1-6 测试用例与仪表操作解耦后的平台
假设,一个软件企业中的每个团队对自动化的需求不同。有些团队需要比较复杂的测试用例,相应地,平台会拥有更丰富的功能;而有些团队只需要执行一些简单的测试,并不需要很丰富的功能。如果都用同一个平台,那么显然会显得比较重,会导致一些团队不愿意花更多的学习成本来部署这样的自动化测试平台。大多数自动化测试平台包含的模块如图1-7所示。
对功能测试开发团队而言,它不需要关心测试结果分析、UI或自动触发执行等模块,他们需要的是和测试用例开发相关的模块,比如测试执行核心模块、测试仪表连接模块和测试结果模块。而对于测试执行团队,除了基本的执行模块,他们还需要数据库管理、测试结果分析、UI、测试环境管理和测试用例管理等模块。而对于CI持续集成,可能需要自动触发执行的模块。
因此,通过合理的模块化分割系统,可以使测试平台变得更加富于弹性,满足不同团队的不同测试阶段的需求。
图1-7 自动化测试平台包含的模块