利用 Sahi 脚本自动生成代码加速 Web 自动化测试开发

阅读数:2618 2012 年 4 月 20 日

话题:DevOps语言 & 开发架构

 本文是《使用 Sahi 测试 Dojo 应用》的延续。在《使用 Sahi 测试 Dojo 应用》中,我们谈到了 ITCL 架构(应用对象层,任务层以及测试用例层)。本文向大家介绍如何编写一个 Sahi 的脚本以自动生成应用对象层的代码从而简化和加速 Web 自动化测试用例的开发。

一. 概述

之所以有可能开发一个 Sahi 脚本来生成应用对象层的代码,主要得益于以下几个方面:

1)面向对象设计模式的应用

Dojo 本身将页面中的控件用面向对象的模式封装成不同的 widget, 而本测试框架用不同的 Javascript 的函数映射到不同的 Dojo 的 widget。这样的一种设计模式,使我们有可能通过搜索页面中的 Dojo widget 以自动生成用来实例化页面控件的代码。

2)强大的 Dojo query 以及 Sahi 的基于上下文的 API

在后面的代码详解中,大家可以看到我们是如何借助 Dojo query 以及 Sahi 的基于上下文的 API 来搜索页面中的 Dojo widget 的。并且,由于 Sahi 本身支持 browser 端的 Javascript 的脚本,因此在我们的代码中可以方便地将 Dojo API 和 Sahi API 混用。

3)Sahi 的基础 -Rhino

因为有了 Rhino 的支持,Sahi 可以进行本地文件的读写,因此使我们能够将生成的结果以文件的形式保存下来。甚至,如果需要的话,我们可以实现基于文件的代码生成模版管理。(关于代码生成模版管理,本文所附带的示例代码没有包含该功能,如果读者感兴趣可以自行尝试)。

简单来说,代码生成 Sahi 脚本的工作过程如下:

  1. 为每种 Dojo widget 定义一个 Javascript 函数。该函数的功能是为特定 widget 代码生成提供元数据。
  2. 使用者在调用代码生成函数时,可以传入一个数组以指定要生成的 widget 的名称,比如按钮、输入域等。默认,代码将生成所有支持的 widget 类型。
  3. 当代码生成函数被调用时,它使用 Dojo query 遍历 DOM tree 以搜索 Dojo widget 的最外层元素(通常是 DIV 并包含 widgetid 属性)。之后,将该元素交予具体的处理 widget 的函数生成声明代码。
  4. 如何知道通过何种方式可以实例化找到的 Dojo widget 呢?在 Dojo widget 函数的公共“父类”中,定义了若干“猜测”函数,例如 guessByName、guessById 以及 guessByLabel。如果需要的话,具体的 widget 函数可以定义自己的“猜测”函数,例如 DojoButton 函数就定义了自己的 guessByText 函数(因为这个函数不具备通用性)。“猜测”的入口是一个叫 guess 的函数,具体的 widget 函数可以传递给 guess 一个数组以指定“猜测”的优先顺序,例如,[this.guessById,this.guessByLabel] 就表明先检查 widget 有没有 id 属性,如果有就生成通过 id 实例化 widget 的代码,如果没有的话,就继续尝试“猜测”label 的方式。如果所有的“猜测”函数都失败,就在 Sahi 的 log 中打印出一条信息,告诉调用者,无法生成这个 widget 的实例化代码。
  5. 最终,把所有生成好的代码语句拼接成一个字符串,保存到 generated 目录下的 appobjscode.sah 文件中。同时,这段代码也会打印在 Sahi 的 log 文件中。

二. 如何运行代码

用来生成应用对象层代码的脚本位于压缩包的 sahidojodemo/codegen 目录中,有 codegen.sah 和 main.sah 两个 Sahi 脚本文件。codegen.sah 定义了代码生成的核心逻辑而 main.sah 只是对其进行调用。我们使用的示例页面依旧是 http://demos.dojotoolkit.org/demos/form/demo.html。读者只需在该页面上弹出 Sahi 控制器并运行 main.sah 脚本即可。下面是该脚本的运行效果图。

 

若要测试生成的代码是否工作,只需要把上图生成的代码粘贴到 appobjs/ JobAppFormPage.sah 中并运行 run.sh。具体的操作步骤请参考《使用 Sahi 测试 Dojo 应用》的“如何运行示例代码”部分。

三. 代码详解

下面对代码进行详细地解释。

1. 函数概览

在 codegen.sah 中有如下一些函数(或者称做“类”)。

  • WidgetMetaData: 定义全局 widget“元数据”的结构。它包含 widget 名称、搜索模式以及处理“类”的名称三个属性。
  • AppObjsCodeGen:负责遍历页面搜索 widget、调用相应的处理“类”生成代码并格式化代码。
  • DojoWidget:负责代码生成的核心逻辑。定义公共的“猜测”函数。
  • DojoTextbox:“继承”自 DojoWidget。提供输入域 widget 的元数据。
  • DojoSlider:“继承”自 DojoWidget。提供滑块 widget 的元数据。
  • DojoCombobox:“继承”自 DojoWidget。提供下拉框 widget 的元数据。
  • DojoButton:“继承”自 DojoWidget。提供按钮 widget 的元数据。

2. 元数据的定义

元数据的定义分为两部分。第一部分是一个 metaData 的数组,它用来声明所有支持的 widget 类型,数组元素是 WidgetMetaData。第二部分就是映射到每种 Dojo widget 的 Javascript 函数,如 DojoTextbox 等,它们提供了特定 widget 的元数据。

以下是 metaData 数组的声明。

var metaData=[]
metaData.push(new WidgetMetaData("textbox","dijitValidationTextBox","DojoTextbox"))
metaData.push(new WidgetMetaData("slider","dijitSlider","DojoSlider"))
metaData.push(new WidgetMetaData("combobox","dijitComboBox","DojoCombobox"))
metaData.push(new WidgetMetaData("button","dijitButton","DojoButton"))

本示例代码中之定义四种 widget。

DojoTextbox 的定义如下。

function DojoTextbox(domNode){
    DojoWidget.call(this,domNode)

    this._getIdentifier=function(){
        var elem=_textbox("dijitReset dijitInputInner",_in(this.domNode));
        return this.guess(elem);
    }

    this._getClassName=function(){
        return "$DojoTextbox"
    }

    this._getSahiFuncName=function(){
        return "_textbox" 
    }
}

_getIdentifier 函数返回对 guess 函数的调用。之所以要传入一个 elem 参数,是因为 name 属性有时不在 Dojo widget 的最外层元素上,而是在其内部某个元素上。例如对输入域 widget 来说,name 属性就是在其内部的 input(type="textbox") 元素上。 这里,我们用了 Sahi 的 _in 函数以保证只在该 widget 内部进行元素搜索。_getIdentifier 函数返回三种类型的值:“label=...”,“=...”或者 undefined。如果返回"label=...",说明该 widget 可以用 label 的方式实例化,于是就会生成形如 var $name=findByLabel("Name *",$DojoTextbox) 的代码。如果返回值是“=...”(这里的由“猜测”函数指定。例如,guessById 会返回“id=...”而 guessByName 返回“name=...”), 将生成形如 var $name=new $DojoTextbox(_textbox("name")) 的代码。其实,只要不等于“label”, 都将以这样的方式生成代码。最后,如果任何一种“猜测”函数都失败了,就会返回 undefined。那么,就会在 log 里看到红色 error 信息“Failed to guess the method for...”。对于 _getClassName 和 _getSahiFuncName 两个函数,大家不难看出,它们分别返回对应的 widget 的“类”函数名称以及相应的用来生成非 label 方式代码的 Sahi 函数名称。

代码行 DojoWidget.call(this,domNode) 用来实现 Javascript 中的“继承”。

3. 如何搜索 / 识别页面中的 Dojo widget

因为每种 Dojo widget 的 class 属性包含的值不同,因此我们可以通过这一点来搜索并识别页面中的 Dojo widget。例如,如果其 class 属性包含“dijitValidationTextBox”,认为它是一个 Dojo 输入域 widget;如果其 class 属性包含“dijitComboBox”,认为它是一个 Dojo 下拉框 widget。

输入域 widget(通过“dijitValidationTextBox”识别)

下拉框 widget(通过“dijitComboBox”识别)

下面我们一起来看看 _formContent 函数。

    this._formContent=function(widgetNames){
        var statements=[];

        for(var i=0;i < widgetNames.length;i++){
            var widgetName=widgetNames[i]
            var widgetMetaData=this._findWidgetMetaData(widgetName)
            var searchPattern=widgetMetaData.searchPattern
            var handleClassName=widgetMetaData.handleClassName
            
            var nodes=dojo.query("[class~='"+searchPattern+"']")

            for(var j=0;j < nodes.length;j++){
                var node=nodes[j]
                if(!node.getAttribute("widgetid")){
                    continue
                }
                var handle=eval("new "+handleClassName+"(node)")
                var statement=handle.getStatement()
                if(statement){
                    statements.push(statement)
                }
            }
        }
        return statements
    }

首先,该函数根据 widget 名称在 metaData 数组中找到对应的元数据。之后,通过 Dojo query 搜索所有 class 属性中包含有 searchPattern 值的元素。当然,这当中有可能会有“假”的,所以,进而判断该元素是否有 widgetid 属性。如果有 widgetid 属性,表明是真的 Dojo widget 元素。接着,实例化对应的处理“类”并调用 getStatement 函数返回针对该 widget 生成的声明代码行。最后,把所有的代码行放入 statements 数组中并返回。

4. 两种形式的声明代码行

在讲解 _getIdentifier 函数时,我们已经提到生成的代码有两种形式:通过 label 或者是通过元素属性值(如 id,name 等)。这个逻辑是定义在 getStatement 函数中的。

        this.getStatement=function(){
            var stmt=""
            this.identifier=this._getIdentifier() 
            if(!this.identifier || this.identifier.substr(-1)=="="){
                 _log("Failed to guess the method for "+this._escape(this._outerHTML(domNode)),"error");
                return;
            }

            var idKey=this._idKeyValue()[0]
            var idValue=this._idKeyValue()[1]
            var varName=this._formVarName(idValue)
            if(idKey=="label"){
                stmt=this.byLabelTemplate.replace('{className}',this._getClassName()).replace('{label}',idValue).replace('{varName}',varName)
            }else{
                var innerElemSahi=this._getSahiFuncName()+'("'+idValue+'")'
                stmt=this.byAttrTemplate.replace('{className}',this._getClassName()).replace('{innerElem}',innerElemSahi).replace('{varName}',varName) 
                return stmt
            }
            return stmt
        }

函数的开始对 _getIdentifier 的返回值进行解析。之后,通过一系列的字符串 replace 操作生成最终代码行。

下面是两种不同的代码模版的定义。正如本文开头提到的,读者也可以将代码模版定义在文件中,使代码生成脚本从文件中加载模版定义。

this.byLabelTemplate='var {varName}=findByLabel("{label}",{className})'
this.byAttrTemplate='var {varName}=new {className}({innerElem})'

5.label 的识别

guessById 和 guessByName 比较容易理解。我们一起看看 guessByLabel 函数的实现。

        this.guessByLabel=function(elem){
            var idValue=elem.getAttribute("id")
            if(idValue){
                var label=dojo.query('label[for="'+idValue+'"]')[0]
            }
            
            if(!label){
                var label=this.domNode.previousSibling.previousSibling
            }

            if(label){
        	return "label="+_getText(label);
            }
	}

因为绝大多数 label 都有一个 for 属性,该属性的值对应其附属的元素的 id 属性值。所以,guessByLabel 首先得到 widget 元素的 id 属性值(注意,这个 elem 有可能是 widget 的一个内部元素,而不是 widget 的最外层元素),然后,通过 Dojo query 查找 for 属性为此 id 值的 label。如果没有找到,它会通过相对位置获取 label 元素 (通常 label 元素和其附属的元素是紧邻的)。最后,调用 Sahi 的 _getText 函数返回该 label 元素的文本信息。

为了配合这两种识别 label 的方式,core.sah 中的 findByLabel 函数需要修改如下。所不同的是,findByLabel 通过相逆的操作由已知的 label 文本识别对应的 Dojo widget。

function findByLabel($labelText,$className){
    var $label=_label($labelText)
    var $wid=getAttribute($label,"for")
    if($wid){
       _set($id,findIdByWID($wid))
    }
    
    if($id){
        var $dojoWidget=_byId($id)
    }else{
        var $dojoWidget=$label.nextSibling.nextSibling 
    }
    return new $className($dojoWidget)
}

6. 增加新的“猜测”函数

如果需要增加新的“猜测”函数,并且它具备一定的通用性,可以将此函数添加到 DojoWidget 函数中。具体写法可参照 guessById 和 guessByName 函数。另外,需要修改 guess 函数中的如下代码,这样才能把它加入到默认的“猜测”函数列表中。当然,你也需要考虑它的“优先级”,从而把它放在数组中合适的位置。

	this.guess=function(elem,userGuessFuncs){
            var guessFuncs=userGuessFuncs
            if(!guessFuncs){
                guessFuncs=[this.guessById,this.guessByName,this.guessByLabel] 
            }
	    for(var i=0; i < guessFuncs.length; i++) {
                var guessFunc=guessFuncs[i];
		rt=guessFunc.call(this,elem)
		if(rt){
	            return rt;
		}
	    }
	}

具体的 widget 处理函数也可以通过 userGuessFuncs 参数指定自己的”猜测“函数以及定义自己的”猜测“顺序。比如 DojoButton,因为它的识别方式比较特殊,我们就在 DojoButton 中定义了 guessByText 函数,并把它作为第二个参数传给 guess 函数。读者如果有类似情况,可以仿照这段代码定义特定的“猜测”函数。

function DojoButton(domNode){
    DojoWidget.call(this,domNode)

    this._getIdentifier=function(){
        var elem=this.domNode;
        return this.guess(elem,[this.guessByText]);
    }

    this.guessByText=function(elem){
        var widgetId=elem.getAttribute("widgetid")
        var labelId=widgetId+"_label"
        var label=_span(labelId,_in(elem))
        var buttonText=_getText(label)
        return "text="+buttonText 
    }   

    this._getClassName=function(){
        return "$DojoButton"
    }

    this._getSahiFuncName=function(){
        return "_span" 
    }
}

7. 生成文件

Sahi 提供了 _writeFile 函数进行文件写操作。下面是 main.sah 的代码。$filePath 定义了文件的保存路径。第三个参数是布尔型,表示是覆盖原文件还是进行追加操作 - True 表示覆盖原文件。Sahi 也可以读写 CSV 文件、重命名文件以及删除文件。具体请参见 http://sahi.co.in/w/miscellaneous-apis

var $filePath='generated/appobjscode.sah'
_set($fileContent,new AppObjsCodeGen().gen());
_writeFile($fileContent,$filePath,true);

四. 结束语

本文向读者介绍了如何通过 Sahi 脚本生成应用对象层的代码来简化和加速 Web 自动化测试的开发。在实际应用中,由于不少读者会在 Dojo widget 的基础上再进行封装,因此,本文附带的代码未必可以直接使用。但是,读者可以借鉴这当中的思路。希望,本文对读者能有所帮助。

代码下载


给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。