读《实战 GUI 产品的自动化测试》之:第二步,构建利于维护的自动化测试系统

转载自:http://www.ibm.com/developerworks/cn/rational/r-cn-guiautotesting2/

基石——IBM 框架简介

Rational Functional Tester 是通过录制和回放机制来实现自动测试的,对于每个脚本,都有独立的对象映射文件,包含对测试对象(TestObject)的定义。当对同一个应用程序界面录制多个脚本时,会重复地定义测试对象。随着工程规模的扩大,维护代码将会逐渐出现困难。因为,当被测程序界面发生变化时,用户必须更新相关的每个对象映射文件。测试用例数量越大,维护工作就会变得更繁重。为了解决代码重用的问题,IBM QSE 团队总结并在很多测试项目中应用了叫做 IBM 框架的(又称 ITCL)代码结构。

此架构包括三个层次:

  • 应用对象层:该层包含以被测程序用户界面元素逻辑关系组织的类。这些类又基于每个更基础的类,比如按钮和文本框。这些类将 RFT 的测试对象完全封装起来,并使它们能被上层代码重用。
  • 任务层:包括以被测程序功能组织的类,这些类中的函数通过调用应用对象层的类,能够执行连贯的操作步骤以实现某些功能,并能被多个测试用例使用到。
  • 测试用例层:调用应用对象层和任务层的代码,按测试用例的要求执行测试步骤,验证测试结果和输出日志。
图 1. IBM 框架(又称 ITCL)

图 1. IBM 框架(又称 ITCL)

测试用例层名如其意,一看就明白。下面,让我们分别来仔细看一看作为三层架构基础的应用对象层和任务层:

应用对象层

应用对象层包含两部分内容:测试控件库和界面定义。

被测程序中的每种控件都能在测试控件库中找到对应的类,比如按钮和文本框会有对应的类:NPushButton 和 NTextField。这些类提供了基本的方法让用户操作控件和获取控件属性。

被测程序中的界面也在应用对象层被定义成类,它包含着这一界面中所有控件信息的定义。以图 1 所示消息对话框为例,它在应用对象层中对应的类叫做 DlgSpellCheck(见例 1),里面定义着不同的方法。每个方法用于获取界面上的一个控件。通过这个方式,被测程序的每个界面元素,都在应用对象层中有唯一的定义。任务层调用应用对象层的方法得到控件对应的类,再调用控件类的方法来操作控件和获取验证信息。

图 2. Lotus Notes 拼写检查对话框显示

图 2. Lotus Notes 拼写检查对话框显示

例 1. 拼写检查对话框的定义与使用
应用对象层中:

public class DlgSpellCheck extends NDialog{
 public DlgSpellCheck(){
 super(“Spell Check”);
}

 // 获取“Skip”按钮
 public NPushButton getSpellIgnore(){
 return new NPushButton(“Skip”,this);
}

 // 获取建议列表
 public NListBox getSpellList() {
 return new NListBox(new ControlID(iddlgSPELL_LIST), this);
 }
 
…// 略去其余方法
}

任务层中:

new DlgSpellCheck().getSpellIgnore ().click(); // 点击 Skip 按钮

例 1 中,NDialog 是控件库中对话框对应的类,它提供测试对话框的基本方法,例如 exists 方法判断该对话框是否显示在屏幕上,clickXButton 能让用户点击 X 按钮来关闭对话框。DlgSpellCheck 是对话框的一个派生类,增加了它自己特有的方法,通常以 get 开头。需要注意的是,此处的代码仅用于演示应用对象层定义对象的规则,实际应用时,还需要对字符串资源做相应定义,此处不再介绍。

应用对象的特点包括:

  1. 类与被测程序界面元素一一对应。
  2. 通过使用标题,索引和 ID 等信息匹配控件。
  3. 定义 get 方法以获取子界面元素。
  4. 抛出异常表示方法执行失败。

通过应用对象层,我们将能够直接获取每一个界面元素的控制类,而不用在任务层和测试用例层直接访问底层的代码。

任务层

任务层按照被测程序的功能,将可以重复使用的测试步骤定义在一个个类里面。类中的每个方法,都可以完成某个特定的步骤。以 Lotus Notes 为例,它有拼写检查这一功区域,相关的测试用例需要测试人员执行替换错误单词和添加自定义单词等功能。针对这一需求,我们可以写一个 SpellTask 类,包含这 2 个方法:replaceMisspelledWord 和 addWordToDictionary,如例 2 所示。

例 2. 定义任务层的方法
public class SpellTask{

 // 将错误单词替换成正确的
 public boolean replaceMisspelledWord (String sWrongWord, String sCorrectWord){
DlgSpellCheck spell = new DlgSpellCheck();

if(!sWrongWord.equals(Spell.getSpellEdit().getText()){
 // 如果对话框上显示的错误单词与预期不符,报错
Logger.logError(“Wrong word is not marked”);
}else{
 // 从列表选择正确单词,并确定
spell.getSpellList().select(sCorrectWord);
spell.getOK().click();
}
}

// 略去其余方法
...
}

例 2 中的 createMemo 方法用到了两个类 MailActionBar 和 Memo,它们分别对应于被测程序中的任务栏和邮件编辑界面。该函数通过调用 get 方法得到控件对应的类,然后对控件进行操作。

任务层方法的特点包括:

  1. 需要操作一个以上的应用对象
  2. 提供多个参数来支持尽可能多的测试用例
  3. 不包含测试用例验证点的验证
  4. 返回 boolean 值表示成功与否

通过任务层,我们把一些常用的执行步骤剥离了出来,方便这些步骤的共享和维护。

小结

IBM 框架的这一分层方式并不局限于某个测试工具和被测应用程序,它是自动测试通用的一个架构,因此在 IBM 内部广泛使用。此框架的优点有:

  • 分离实现和应用。将对象的定义以面向对象的方式封装到了应用对象层中,同时也隐藏了 RFT 的测试对象与 API。编写测试脚本的人员并不需学习 RFT 的接口,测试对象和对象映射,也能开始工作。
  • 因为每个界面对象只有唯一的定义,维护代码的工作量被大大减少。
  • 测试脚本结构清晰。

扩展——编写测试用例的技巧

有了应用对象层和任务层,我们可以开始编写测试用例。实际上,大部分的脚本开发人员日常所工作的范围集中在测试用例层,而应用对象层和任务层通常由更富经验的核心团队负责定义和维护。一般来讲,编写测试用例并不复杂,主要就是根据用例要求和执行步骤调用任务层和应用对象层的代码,验证测试结果并收集必要的测试日志。不过,我们在编写测试用例的实际工作中也总结出一些容易被忽略的问题,在这里我们就这些需要特别注意的方面做个简单的介绍。

关于执行环境的准备和清理

每个测试用例都有自己的执行环境。比如在 Lotus Notes 的自动化测试中,有的用例需要确认和切换当前用户,拷贝并打开测试用数据库,还有些测试用例会要求做一些特殊的偏好设定等等。由于测试集中所有的测试用例将会顺序执行,我们必须保证在每个测试用例开始时配置好执行环境,而在测试用例结束时将环境清理干净,恢复到初始状态。否则,后继测试用例的执行就有可能因为预料之外的条件而导致测试不通过甚至产生异常,这并不是我们所期望的结果,而且通常说来,这种问题往往需要花很大的力气来进行追踪。

那么清理环境都需要做些什么呢?仍然以 Lotus Notes 的自动化测试为例,我们需要检查如下几点:

  • 如果切换了新的用户 ID,那么最好切换回原来的缺省用户 ID
  • 如果改变了某些偏好设定,最好将这些设定复原
  • 如果创建了数据库,最好删除这个数据库
  • 如果创建了针对服务器数据库的本地副本,最好删除这个副本
  • 如果新建了邮件消息,那么需要视情况而定,假如不是后续测试用例所必需的,那么最好删除这些新建的邮件信息
  • 如果有未关闭的对话框或者消息框,一定要关闭
  • 关闭所有的标签页面

环境的准备和清理代码要放在测试用例主体的前面和后面,最好分别放在单独的函数中,这样代码结构看起来比较清晰。在 Lotus Notes 的自动化测试用例中,环境的准备一般都会被放在 setUpTestCase() 中而环境的清理都会被放在 cleanUpTestCase() 中。

您可以根据项目和脚本的实际情况来决定环境清理的内容,但需要注意的是,环境清理非常重要。无论测试用例的执行过程中是否出现异常,您都需要保证清理代码能够顺利执行完成。

关于步骤间的检查与等待

步骤间的检查和等待对于正确和平顺地执行测试用例非常重要。

举个简单的例子:当我们选取某个菜单项后,一个对话框会被弹出,然后我们需要对对话框进行操作。那么,当正确出发菜单选取的事件后,对话框能不能被弹出,何时被弹出,对于下一个步骤地执行至关重要,若对话框尚未被弹出就去执行下一步骤,比如说点击某个按钮,那么测试必定不通过。我们一定要在每个步骤之前,确认上一步骤已成功执行。比如,预期的对话框已正常打开。这些手工执行时自然而然会做的检查,我们在写测试脚本时会很容易忽略。

最常见的检查就是某个预期的界面元素是否出现或者消失。为了减少测试脚本代码的复杂度,我们在对象层为每个图形控件都扩展出 2 个方法exists(timeout) 和 waitForNonExistence(timeout),前者检查控件是否被打开并显示在屏幕中,后者检查控件是否已被关闭,我们在这两个函数中定时检查界面元素,直到控件显示、消失或者超时。这样,在编写测试用例时,我们只需要使用这两个方法进行验证,并指定超时时间。如下面的代码片断所示:

例 3. 执行步骤间的检查
…
// 选取菜单项
Menu.mCheckSpelling.pick();

// 等待对话框被弹出
DlgSpellCheck spellDlg = new DlgSpellCheck();
Logger.logCompareFatal(true, spellDlg.exists(LocalSettings.giTO), "Verify dialog exist");

// 点击 Ignore 按钮
spellDlg.getSpellIgnore().click();
…
// 点击 Cancel 按钮并等待对话框关闭
spellDlg.getEcancel().click();
spellDlg.waitForNonExistence(LocalSettings.giSTO);
…

当然 exists(timeout) 和 waitForNonExistence(timeout) 并不能解决所有问题,比如上面例子中,点击 Ignore 按钮后对话框的内容和布局都会发生变化,但是对话框本身并不会被关闭,所以在这里插入等待的代码是必要的。在实际测试中,我们总结出一些针对步骤间等待的方法和原则:

  • 尽量避免直接使用 sleep(timetime),因为我们无法预知在不同的机器上,在不同的平台上,等待多少时间是合适的;时间太长影响执行效率,时间太短在某些配置较低的机器上测试可能不通过。
  • 许多方法支持超时等待,如 exists(timeout) 和 dbOpen(timeout),尽量使用这些方法代替 sleep()
  • 尽量使用轮询,而非直接等待固定长度的时间。
  • 在不得不使用 sleep()的情况下,等待预定义的时长。我们定义了 giSTO、giTO 和 giLTO 三个常量分别表示较短时长,正常时长和较长时长,测试人员可以在不同的测试环境中设定这 3 个常量的具体数值

关于异常处理

我们永远无法预测哪一步会导致自动化测试的失败以及异常退出,也许是测试用例的某一步骤编写的不合理,也许是因为产品出现瑕疵,因此异常处理也是测试用例编写中从最开始就必须注意的。

在 Lotus Notes 的测试用例中,我们建议所有的测试用例主体代码都要位于如下 try/catch 代码块中

例 4. 注重异常捕获的脚本模板
try{ 
…
// 初始化测试环境
setUpTestCase();
…
 
// 开始测试代码
 …
// 结束测试代码
}catch (IllegalArgumentException e) {
ScriptTool.templateIllegalArgumentException (e);
}catch(RationalTestException e){
ScriptTool.templateRationalTestException (e);
}catch (RuntimeException e) {
ScriptTool.templateRuntimeException (e);
}catch(Exception e) {
ScriptTool.templateException (e);
}finally {
ScriptTool.templateFinally(sScriptName);
} // end finally
	
try {
…
// 清理测试环境
cleanUpTestCase();
ScriptTool.cleanup ();
}catch (Exception e) {
Logger.logError("Exception thrown from CleanupTestcase");
Logger.logError("Cause of the exception: " + e.getCause());
Logger.logException(e);
}finally{
 // 标志测试用例结束
ScriptTool.endTC(sScriptName);
}

即使在脚本执行过程中出现异常,我们也可以在日志中输出完整的脚本执行记录,并完成环境的清理工作。cleanUpTestCase() 和ScriptTool.endTC(sScriptName) 将确保被执行。

进阶——数据驱动和配置驱动

在 IBM 框架的支持下,我们已经可以写出结构清晰的测试脚本。对于一些复杂的测试情况,我们还可以通过其他一些技巧来拓展脚本的灵活性,其中就包括数据驱动和配置驱动。下面,先让我们来看一看数据驱动。

数据驱动

有时候,我们需要在应用中输入不同的数据,并在进行一系列同样的操作后验证输出结果的正确性。比如说 Lotus Notes 提供的联系人自动补全功能,在用户输入部分联系人名称之后,Notes 将通过搜索后台的最近联系人列表、用户自定义联系人列表和服务器目录来完成联系人名称的补全工作。

图 3. Notes 的联系人自动补全功能

图 3. Notes 的联系人自动补全功能

对于这个测试用例,我们需要验证不同的用户名称输入,包括:

  • Notes ID(包括标准格式和精简格式)
  • Internet 邮件地址
  • 昵称
  • 包括特殊字符(&,¥,#...)的名称
  • 超长的名称
  • 包含多国语言字符的名称

显然,我们可以把这些名称作为数据提供给同一个脚本来进行操作,脚本负责在 UI 上找到“收件人”文本框,输入预设的名称,并验证预设的收件人显示名称。通过数据驱动,我们可以快速的扩展脚本的覆盖范围。

RFT 对数据驱动方法提供了良好的支持,其核心就是数据池技术。数据池就像一张电子表格,我们可以自由定义表格中的字段并把预设的测试数据和测试结果保存到数据池中。RFT 可以为脚本自动分配独占的数据池,也支持在多个脚本间共享公用的数据池。

图 4. RFT 的数据池

图 4. RFT 的数据池

在数据池的使用方面,RFT 的脚本录制工具直接提供了对数据池的支持,我们可以通过简单的拖拽和配置在脚本中自动插入数据池的访问代码。自然的,我们可以直接通过 com.rational.test.ft.datapool 包中提供的 API 更灵活的访问数据池中的数据。RFT 提供了对数据池中条目的顺序访问(SequentialIterator)和随机访问遍历器(RandomIterator),如果有必要,我们还可以通过实现 IDatapoolIterator 接口来自定义数据池的遍历方式。

使用了数据池技术之后,我们的联系人名称自动补全测试脚本可以表现为:

例 5. 使用顺序迭代器实现数据驱动测试
public class NameAutoComplete extends NameAutoCompleteHelper
{
 public void testMain(Object[] args)
{
…
// 创建空邮件
Memo memo = MailTask.createEmptyMail();

// 获得收件人控件
NTextField field = mail.getMailToField();
 
// 为数据池中每一条记录运行测试
while (!dpDone())
 {
 // 读取测试用名称
 String inputName = dpString("Test Name");
 // 读取期待结果
 String expectedResult = dpString ("Expected Result");
 // 输入并自动补全
 AutoCompleteTask.input(field, inputName);

 // 获取并验证结果
 String result = field.getText();
 Logger.logCompare(expectedResult, result, “VP: verify the result is correct.”);

 // 清空收件人控件
 field.setText(“”);

 // 处理下一个测试用例
 dpNext();
 }
 }
}

通过数据池,我们可以方便的对测试数据进行组织和使用。如果您希望更深入的理解数据池,可以参考 RFT 的帮助文档和本文附录中的推荐文章。

配置驱动

随着产品的不断演化,一些新加入的功能很可能会间接影响到现有的功能区域。比如说,Notes 在 852 中加入了对邮件缓存模式的支持,该功能会自动的下载用户最近的邮件列表到本地,从而可能影响到本地的“最近联系人”列表,进而间接影响到我们的联系人名称自动补全功能。除此之外,Notes 的其他一些新功能配置也可能会与现有功能产生交叉影响,比如 LotusLive 工作模式,站点切换,漫游模式等等。

因此,有一两个现实的问题是我们必须要面对的:如何保证软件产品在日趋复杂的配置模式组合之下,现有的功能区域不受影响?如何使我们现有的测试脚本在基本不需修改的情况下能够适应各种复杂的配置组合?

我们的答案是配置驱动。与直接通过 RFT 执行脚本不同,我们创建了一个脚本调度器来进行测试集的调度和执行,其工作原理如图 4 所示。在每一个测试集的元数据文件中,我们定义了各个功能配置项的配置参数,以及测试集所包含的测试脚本。针对每一个功能配置项,我们均定义了一个配置管理器来负责控制产品功能的具体配置。脚本调度器会在初始化之后,从测试集元数据文件中依次读取配置项,并调用相应的配置管理器来配置产品的实践功能组合。而后,脚本调度器将顺序执行测试集中的脚本,并生成结果汇总。脚本在执行过程中,也可以通过查询配置管理器来获得当前的功能配置,从而进行相应的微调。

图 5. 脚本调度器和配置管理

图 5. 脚本调度器和配置管理

当有新的产品配置需要处理时,我们只需要实现新的配置管理器,并构造新的测试集来测试新配置项和已有配置项的不同组合对现有脚本的影响。而在脚本方面,我们几乎不需要做任何改动。通过配置驱动方法,我们能够更轻松的面对功能组合的变化。

远见——全球化和跨平台

对于一款成功的产品而言,支持多个操作系统,支持多种语言版本也是一个迟早需要面对的问题。比如 LotusNotes,我们对 Windows、Mac、Linux 平台均提供了支持,并同时提供了数十种语言的软件版本。为了保证自动化测试系统对于产品跨品台和全球化支持的灵活性,最好在系统设计之初就了解在这两个方面将会面对的一些基本问题。

对于全球化,我们需要面对的变化包括:

  • 字符串资源的本地化

我们将需要把产品中所有的字符串资源本地化。这意味着我们的自动化脚本中不能包含硬编码的字符串,同样的,我们需要把脚本中需要验证的字符串资源提取出来,和脚本分离。

  • 快捷键的变化

并不是所有语言版本中产品的快捷键都是一样的。对于中文和英文系统而言,“编辑”菜单(Edit)的快捷键都是“Alt+E”,而在意大利语系统中,其快捷键却是“Alt+M”(对应意大利语 Modifica)。因此,快捷键也是我们在应用对象层中需要通过抽象独立出来的对象。

  • 时间、货币格式变化

不同的语言环境下,时间、货币的格式会发生相应的变化。比如“2011 年 6 月 7 日”在英文系统中可能就表示成“2011/6/7”。因此,我们在进行时间和货币的比较时,不能直接使用字符串进行匹配,而要进行适当的转换。

  • 界面布局的变化

一方面,由于不同语言的字符串资源的长度差异,为了更好的显示这些字符串,不同语言版本的产品在界面布局上会有细微的调整。另一方面,如果我们的产品需要支持 Bidi 的话,界面的差异将会更大:在阿拉伯世界中,GUI 界面的布局是从右向左的,和我们常见的中文或者英文系统恰恰相反。因此,我们在编写脚本的时候,最好不要依赖于界面元素的绝对坐标位置。

RFT 对全球化提供了良好的支持,在 developerWorks 上,有多篇文章对这一话题进行了讨论,如果您感兴趣,可以参考本文附录中所列举的参考文献。

同样,我们的测试脚本想要支持跨平台的话也有诸多因素需要考虑,比如:

  • 界面元素差异

我们的 GUI 产品在不同的操作系统上需要支持系统的原生界面,而这些界面元素又有着显著的差异。比如,在 Mac 系统中,窗体的“最小化 / 最大化 / 关闭”按钮是在窗体的左上角,窗体间共享同一个菜单栏而没有独立的菜单栏,滚动条上的“向上 / 向下”按钮缺省位于滚动条的两侧而非底部,等等。随着现代操作系统的演化,不同系统间的界面差异在渐渐扩大。比如,Windows 中新的 Ribbon 界面在 Mac 上就没有对应的实现。

  • 系统对话框

不同系统的系统对话框是不同的,比如说“打开文件”对话框和“打印”对话框。

  • 快捷键

相对于全球化而言,不同系统间的快捷键差异更大。

  • 系统特性支持

作为一个系统原生产品,我们需要支持一些系统所特有的特性,比如 Mac/Linux 下的 Space,Windows 的 Jump List 或者 Mac 的 Dock Icon 功能。对于这些特性,我们往往需要构建专属的测试脚本进行测试。

跨平台给我们带来了更大的挑战,而 IBM 框架为我们应对这一挑战提供了坚实的基础。在了解平台间差异的基础上,我们在应用对象层对不同平台间的差异进行了封装,包括窗体、菜单、系统对话框、快捷键等等。这使得我们在测试脚本层不用再面对这些差异,简化了脚本的复杂度。

参考资料

学习

原文地址:https://www.cnblogs.com/MrCandy/p/4036240.html