前言
好久没更新了,最近家里出了点事一直没顾上. 本来今天这个话题之前都写好草稿了.但因为家里的事, 我一直都没心情完善它. 今天我们讨论一下吧. 之所以把可读性,可维护性,可扩展性放到一块说是因为解决它们的思路往往是很像的. 你做好了一些事情,以上三个问题也就都解决了,例如可读性好了,可维护性也就差不了. 所以接下来我可能在文章中穿插着讲这三个问题,并不是按绝对的先后顺序来说. 同时把这个话题提前到第三章来说, 也是因为我觉得这是一个大家非常容易忽略,但是在case达到一定量级的时候会出现很大的问题的一个点,也许大家觉得我讲这些有的没的根本没用,我们以前没考虑这些的时候也不是照样干活么.那么在下面的文章里,我会跟大家解释,其实这很重要. 我不会讲什么让你们设计高内聚,低耦合 的软件这种废话.我们来实际举一些例子来看看我们一般是怎么做的.
测试框架与测试脚本的目标(部分)
1、Tests as Documentation(你能很容易通过测试脚本理解被测软件的功能) ----可读性
2、Defect Localization(通过测试脚本能够快速定位bug的位置) ----可读性与隔离性
3、Tests should be easy to write and maintain(测试脚本应该是容易编写和维护的) ----可维护性
4、Tests should be easy to improve when product changes(当产品变化时,测试应该是很容易扩展自身以适应变化的)----可扩展性
分层
为了提高我们测试脚本的质量, 分层显然是最常用的方法. 想象一下如果我们把根测试所有相关的东西都放在脚本里那是怎样的一种灾难,每次你去看脚本的时候都会一个头两个大。其一你不知道脚本在干嘛,其二你根本不敢随便动这个脚本。深怕动了哪里就破坏了这条脚本。所以当我们作了分层后,将责任划分出去,分而治之,每一层负责特定的功能,其他层不用担心这些特定的功能。
原则:
测试脚本只关注被测的功能逻辑,其他一切责任分层出去,或交给框架作,或交给其他模块作。
常用的分层方式:
1. 数据驱动,具体数据驱动的实现请看:数据驱动及其变种。把测试参数的构建分离出去,减少脚本复杂度
2. 注册式数据管理,具体实现请看:测试数据管理策略. 我们把测试数据的构建与销毁分离出去,减少脚本复杂度
3. page object,UI自动化常用的模式. 我看到的大家常用的方式就是把页面元素的定义分到单独的类中.下面来看看我曾经怎么做这个分层的.
脚本是这么写的:
driver.page("登陆页面").sendKeys("用户名输入框", "Admin").sendKeys("密码输入框", "1234567").click("登录按钮");
可以看到我定义了一个page的概念.一个页面所有的元素都在这个page里. 只要脚本中选定了某个page,那么他就能随意控制页面操作. 那么page object在哪呢?脚本中我们看不到调用page object的操作,我们看不到你到底用xpath查找的元素还是用id还是用name。请看下一段xml定义
<page name="登陆页面">
<Element eleName="用户名输入框" xpath="//input[@type='text']" id="userNameInput" ifBaseElement="true"></Element>
<Element eleName="密码输入框" xpath="//input[@type='password']" ifBaseElement="true"></Element>
<Element eleName="登录按钮" xpath="//button" ifBaseElement="true"></Element>
</page>
page object在这里,这里面用中文定义了元素名称,以及控件元素到底是用什么方式去查找等信息。当脚本引用任何页面的时候,框架都会去缓存中读取此页面信息,并执行页面元素的控制操作。可以看到我们不仅把页面元素的定义分层出去,还把页面元素的查找过程也都分层出去了。 而且我们可以用自然语言定义控件的名字(英语还是汉语都可以),所以就像上面的代码一样,脚本在做什么一目了然。这就是可读性,我们做的事情跟之前没什么分别,但是我们把责任划分的更详细,脚本中只剩下业务逻辑。我们有一个原则就是脚本中只有业务逻辑。其他一切不相关的要不交给框架,要不交给其他层的模块。
使用类似xml这种可扩展性强的语义存储数据
我们看到上面的xml里还有一个ifBaseElement 属性。 这个是什么呢? 它就是给这些页面元素打个标签,这些控件是属于页面基本元素,这样我们可以通过下面一段代码把所有带有这个标签的页面元素全找出来。
List<String> eleNames = driver.getBaseElementsNameOfPage("登陆页面");
for(String eleName:eleNames){
WebElement element = driver.findElement("登陆页面."+eleName+"");
Assert.assertNotNull(element);
}
看到效果了么? 这样我可以验证所有这些页面基本元素在页面中是存在的,这就是我们UI自动化策略中的静态元素验证。我们不用再一行一行去写代码验证了。而是通过xml这种方便扩展的定义遍历出所有的静态元素。这是一种方式,你也可以通过定义xml文件的属性扩展出很多功能。这是可扩展性。记得我的那篇数据驱动及其变种么?之后的关键字驱动框架就使用xml在数据驱动的基础上扩展而来的。同时xml是一种很清晰很结构化的定义方式。实际上xml本身的可读性就不低。可扩展性和可读性上去了,可维护性也就差不到哪去
代码复用:抽象一切可抽象的,减少一切可能的代码相似与重复
记住一点:代码越少越简单,维护起来就越方便。简单即是美
还是用UI自动化这个例子吧,我们看到上面讲xml可扩展性的时候。我们可以通过定义一个标签ifBaseElement 来帮助做静态元素验证。但是java里普遍也就是用dom4j等工具遍历xml文件,你为ifBaseElement 需要写一套遍历,你加另一个属性可能还要一套遍历。或者xml树结构改了,我们在已有的标签下又加了一套新的标签等情况。都需要重写遍历。而且一层又一层的for循环也挺让人崩溃的。外人不知道你这段代码在干吗。可维护性,可读性,可扩展性都差的要死。那我们一般怎么做呢。看下面一个例子。
注:有个方案是写迭代器(请Google迭代器模式),for循环过多,而且复杂的时候一般使用此模式增加可维护性和可读性。不过在xml遍历的场景中,应变能力不强。xml变化,迭代器也必须变化。所以我一般使用解释器模式遍历xml和json
XMLParser.parser(pageObject, "page/Element$(ifBaseElement=true).eleName");
OK,大家看到了吧,一个解释器接收一个string和xml对象为参数。String就是我们自定义的语法,上面的意思就是取出page节点下的Element节点中所有ifBaseElement属性值为true的eleName属性的值。这样就满足上一个例子的遍历出所有的页面基本元素的需求了。通过定义一个简单易懂的语法(一开始我想做成根sql语句一样的语法的,后来觉得太麻烦了)满足了我们各方面的需求:使用者很容易使用,也很容易看懂这段代码再作什么。很容易接受变化,xml改变了我们改变一下字符串就行了。扩展性也很好。语法很容易进化。基本上可读性,可扩展性,可维护性都做到了。
举些json的例子:
dataList[id=89898,54546,90723,1,90724,90725,54545]/* 取json中dataList数组中 id为这些的所有的值,*代表查询所有
dataList[id=89898,54546,90723,1,90724,90725,54545]/id 取json中dataList数组中 id为这些的所有的值,id代表只查询id
dataList[0~5]/* 取json中dataList数组中前6个元素
dataList[*]/* 取json中dataList数组中所有元素
想知道实现方式的自行google解释器模式吧,这个模式比较大,我说不清楚
再举个例子,我们写脚本的时候一定会验证返回值,有时候这个值可能是简单的数字或者字符串。有些时候可能就是复杂的对象了。这个时候对复杂对象作验证就比较痛苦,每个属性都写断言的方式简直要人命。为了解决这个问题,我们的方式是java反射机制加上责任链模式
VerifyHandler handler = VerifyHandlerFatory.createVerifyHandler();
String[] notverify = {"Task:id"};
handler.PassRequest(copyTask, sourceTask, notverify);
大家看上面的代码,就是比较两个task对象是否相等,第一行代码是创建一个责任链对象,第二行代码规定了什么东西不需要验证,因为task的ID是随机生成的不可能相等。最后我们把两个对象仍进去就行了。你不用管它怎么验证的,责任链在运行到javaBean类型的时候,就会用java反射解析两个对象的每一个属性并调用链表中其他的节点做相应的断言。是不是很好用?不仅仅是javaBean类型,JSON,数组,List,Map,File你全都能不管三七二十一的仍进责任链里。这下子写脚本的人可爽了,以前我们最怕的就是一个ORM映射出来的字段百八十个的,光是写断言就写到手软。现在完全木有这个问题了。如果有新的验证类型出现,你只需要在责任链表里增加一个节点对应这个类型作验证就好了。不需要一大堆的if else 递归调用的,可维护性,可扩展性很不错。现在可读性也好了,就一行验证代码,你肯定知道脚本在干吗。
说一下大概实现思路吧。责任链可以是单向链表,也可以是循环链表,甚至你可以发展成树形结构(暂时我在测试中没碰见这种复杂结构,开发那常碰见),每个节点对应一种类型,如果判断当前类型是该节点应该处理得,就处理。如果不是就传递给下一个节点处理,依次类推,直到遇到跳出链表的点(例如验证结束)或者是到达链表的尾部。中间如果遇到容器类型例如一个javaBean或者一个List等等,就循环遍历每一个值依次传递下去。你可以理解为你为链表作线性遍历,但是链表给传递进来的对象做的事树的先序遍历(深度优先)。
上图所有的类,所有的节点继承VerifyHandler抽象类,VerifyAlgoChain是链表的容器,VerifyHandlerFatory是组装链表的工厂类。下面贴一个List类型的代码
/** * 验证list类型 * * @author Gaofei Sun * */ public class ListType extends VerifyHandler { @Override public Boolean PassRequest(Object actualValue, Object expectedValue, String fieldName_no,Params info,String[] notVerifyFlag) { // 如果对象属于List类型就验证 if (expectedValue.getClass().isAssignableFrom(List.class) || expectedValue.getClass().isAssignableFrom(ArrayList.class) || expectedValue.getClass().isAssignableFrom(LinkedList.class)) { List<?> expectedValueList = (List<?>) expectedValue; List<?> actualValueList = null; try { actualValueList = (List<?>) actualValue; } catch (ClassCastException e) { e.printStackTrace(); Assert.assertTrue(fieldName_no + "返回值并不是List类型,而是:" + actualValue.getClass() + " 类型", false); } if (actualValue == null) { if (expectedValueList.size() == 0) { return true; } else { Assert.assertTrue(fieldName_no + " 返回值中的List为空,但是预期值不是", false); } } Assert.assertEquals("输入的List:" + fieldName_no + " 的大小与返回的不等", expectedValueList.size(), actualValueList.size()); VerifyHandler handler = VerifyHandlerFatory.createVerifyHandler(); for (int i = 0; i < expectedValueList.size(); i++) { // 取出所有对象继续在责任链表中传递。 handler.PassRequest(actualValueList.get(i), expectedValueList.get(i), fieldName_no,info,notVerifyFlag); } return true; } // 不属于List类型,传递给下一个节点 return nextHandler.PassRequest(actualValue, expectedValue, fieldName_no,info,notVerifyFlag); } } |
好了具体的实现原理请大家自行Google 责任链模式
当然了大家也许会说我不用这个屁的责任链模式也可以阿,我写N个if else 加递归调用加java反射也可以实现。那么我们通篇都在说什么呢?可读性,可扩展性,可维护性。如果你这么写你指望谁愿意接手你的代码。反正我写这种代码出来我老大肯定抽我。多少个公司的代码规范里都是严禁出现这种情况的
活用java注解和反射(python中应该也有相关的机制)
这个例子是在 测试数据管理策略一章中讲到的注册式数据管理。看一下下面的例子
@DataBaseFile(filePath="defaultProject.xls",scope=Scope.CLASS)
public class UnitTestNew extends UnitCaseBase{
上面的代码在类的基础上加一个DataBaseFile的注解,然后再基类中我们有如下定义:
/** * 根据数据文件内容解析出的数据库执行语句的集合,用来初始化和销毁数据库。 初始化方法读取数据文件执行数据库insert语句并给此变量赋值,销毁方法在测试结束后读取此变量执行销毁操作 */ private List<DataEntity> dataEntityList; /** * 表明子类的DataBaseFile注解 */ private DataBaseFile data; /** * 表明子类的DataBaseFile注解中数据文件的路径信息 */ private String[] filesPath; /** * 表明子类的DataBaseFile注解中执行初始化和销毁的策略信息 */ private Scope scope; /** * 构造方法,获取子类的@DataBaseFile信息 */ public UnitCaseBase(){ register = false; data = this.getClass().getAnnotation(DataBaseFile.class); dataEntityList = new ArrayList<DataEntity>(); if(data!=null){ this.filesPath = data.filePath().split(","); this.scope = data.scope(); } } |
我们可以看到,在子类中,我们使用注解的方式制定数据文件的路径和作用域,基类默认构造方法会使用反射的方式去读取注解的信息,然后再基类中定义好了方法去做测试测试数据的初始化和销毁。如下:
// 供子类重写,用于setup测试用例 protected void methodSetUp(){} // 供子类重写,用户销毁测试用例 protected void methodTearDown(ITestResult result){} // 供子类重写,用于在测试类开始前执行初始化工作 protected void classSetUp(){} // 供子类重写,用于在测试类结束后执行销毁工作 protected void classTearDown(){} /** * 测试用例的初始化 */ @BeforeMethod protected void methodDataBaseSetUp(){ this.setUpDataBase(Scope.METHOD); this.methodSetUp(); } /** * 测试用例的销毁 */ @AfterMethod protected void methodDataBaseTearDown(ITestResult result){ this.methodTearDown(result); // 判断子类是否注册了测试数据 if(dataEntityList!=null&®ister.equals(true)){ this.destoryData(); } this.tearDownDataBase(Scope.METHOD); } /** * 测试类的初始化 */ @BeforeClass protected void classDataBaseSetUp(){ this.setUpDataBase(Scope.CLASS); this.classSetUp(); } /** * 测试类的销毁 */ @AfterClass protected void classDataBaseTearDowm(){ this.classTearDown(); this.tearDownDataBase(Scope.CLASS); } |
我们可以看到基类定义的before系列的方法中有着针对数据作用域进行初始化和销毁的操作。并且留给子类接口扩展销毁和初始化操作。一般情况子类只需要使用注解规定数据文件的路径和作用域就可以了。这种基类定义行为,子类定义实现的方式是 模板模式 的变种. 这下我们可以看到我们的脚本类只需要继承这个基类,使用一个简单的注解就可以不用管数据的销毁与创建了. 我十分推荐这种方式制作测试框架.