使用 XPath 在 Rational Functional Tester 中动态识别对象
http://www.ibm.com/developerworks/cn/rational/r-cn-rftxpathdynobj/
IBM Rational Functional Tester(RFT)是一个非常灵活的自动测试工具。通常情况下,用户可以通过 ObjectMap 来存储 GUI 对象的识别信息。ObjectMap 中的测试对象必须有严格的层次结构,位置或者某个不变的属性,非常适用于一些静态的不易变化的 GUI。然而被测程序总是会包含一些动态的 GUI 对象,这些对象的层次或者属性在运行时具有不确定性。RFT 提供了一些 API 来处理这些对象,比如 TestObject 的 find 和 getChildren 方法。这些 API 能够解决查找动态变化的 GUI 对象的问题,但是直接使用它们会增加脚本的逻辑复杂度,提高了开发难度,同时使得脚本也不易维护。本文将介绍如何引入 XPath 作为测试对象的识别语言,从而简化动态对象的识别问题。
RFT 中的 Find 方法的输入参数是各种查询条件或这些条件的组合。可用的查询条件包括:atProperty、atChild、atDescendant、atList 等等。
针对复杂的 GUI 对象,我们不得不通过大量的 Java 或 VB 代码来实现这些条件组合。如果能用一个比较简单的字符串来表示复杂的查询条件,肯定会受到脚本开发者的欢迎。于是我们想到了在 XML 技术里面的 XPath。XPath 被证明是一个非常灵活和简便易学的轻量级查询语言,广泛应用于在 XML 文档中查询文档中特定文本、元素和属性。我们把它应用于在 RFT 中查询 GUI 对象将是一个不错的想法。
首先让我们通过几个实例来比较一下使用 XPath 和直接使用 RFT API 来识别对象的区别。当用户测试一个 eclipse 应用程序时,他需要动态查找一个按钮,于是使用 RFT API 写下如下代码:
find(atList(atChild(".class", "org.eclipse.swt.widgets.Shell", ".captionText", "Hello"), atChild(".class", "org.eclipse.swt.widgets.Button", "text", "OK"))); |
使用 XPath 时 , 如下:
findByXPath( "org.eclipse.swt.widgets.Shell[@captionText= 'Hello']/org.eclipse.swt.widgets.Button[@text='OK']"); |
XPath 使代码更加简洁,在更加复杂的查询条件下更加明显。比如,动态查找一个按钮,它总是所有按钮中的最后一个。可以使用如下代码:
findByXPath(shellTO, "org.eclipse.swt.widgets.Button[last()]") |
而如果不用 XPath 则需要:
TestObject[] tos = find(atChild(".class", "org.eclipse.swt.widgets.Button")); TestObject expectedTestObject = tos.length == 0 ? null : tos[tos.length - 1]; |
XPath 中还能支持逻辑,算数运算以及函数,可以将复杂的多重的查询条件用一个字符串来表示,特别值得一提的是它还可以方便地根据子孙对象的属性来识别对象,使测 试脚本更加精炼。如查找一个对话框,它的标题为”Error”并且它的里面包含一个内容为”An error occurs”的 Label:
findByXPath(“org.eclipse.swt.widgets.Shell[@text='Error' and descendant:: org.eclipse.swt.widgets.Label/@text='An error occurs']”); |
而如果不用 XPath 则需要:
TestObject[] tos = find(atChild(".class", "org.eclipse.swt.widgets.Shell", "text", "Error")); TestObject expectedShell = null; for (int i = 0; i <tos.length; i++) { TestObject[] labels = tos[i].find(atDescendant(".class", "org.eclipse.swt.widgets.Label", "text", "An error occurs")); if (labels.length > 0) { expectedShell = tos[i]; break; } } |
显然直接使用 RFT API 要复杂的多。随着被测应用程序的不断更新,识别 GUI 对象的逻辑也要随时更新,就不得不修改大量的脚本代码,这大大增加了维护成本。
接下来介绍如何实现这个想法。应用程序中的 GUI 对象是一个层次结构,一个对象有父对象也有子对象形成一颗树。把 GUI 对象看成 XML 中的元素,对象的类名就是 XML 中的元素标签,对象的属性就是 XML 中元素的属性。
我们以 eclipse 中”Add Bookmark”对话框 ( 图 1) 为例,它对应的 XML 表示如清单 1 所示。GUI 对象的父对象和子对象可以由 TestObject.getParent() 和 getChildren 得到。TestObject.getObjectClassName() 和 getProperty 可以获得对象的类名和属性。我们需要做的就是将 XPath 转换为对 RFT 底层 API 的调用。
<org.eclipse.swt.widgets.Shell captionText="Add Bookmark"> <org.eclipse.swt.widgets.Composite> <org.eclipse.swt.widgets.Composite> <org.eclipse.swt.widgets.Label text="Enter Bookmark name:"/> <org.eclipse.swt.widgets.Text priorLabel="Enter Bookmark name:"/> </org.eclipse.swt.widgets.Composite> <org.eclipse.swt.widgets.Composite> <org.eclipse.swt.widgets.Button text="Cancel"> <org.eclipse.swt.widgets.Button text="OK"> </org.eclipse.swt.widgets.Composite> </org.eclipse.swt.widgets.Composite> </org.eclipse.swt.widgets.Shell> |
解析 XPath 表达式有些困难,不过 Jaxen 可以帮助我们,它是一个开源的 XPath 引擎,提供了一种适配器机制以支持用 XPath 查询非 XML 模型。这一特性正要我们想要的。
要想在 Jaxen 中适配 RFT 的模型,第一步要做的是实现 Navigator 接口,如果想提高效率可以实现NamedAccessNavigator接口。Jaxen 提供一个 Navigator 接口的默认实现 DefaultNavigator。定义我们的 RFTNavigator 类,使其扩展 DefaultNavigator 并实现NamedAccessNavigator接口。这里介绍一些关键方法的实现,完整代码请参见下载部分的示例源码。
清单 2 显示了如何转化 TestObject 的属性为 XML 中的属性节点来实现 getAttributeAxisIterator 方法。需要注意的是 TestObject 的有些属性名以点开头,这不符合 XML 命名规则,可以用下划线作为转义符进行转义。
清单 2. getAttributeAxisIterator 的实现
/** * Get the attributes of the element */ public Iterator getAttributeAxisIterator(Object contextNode) { TestObject to = (TestObject) contextNode; Hashtable props = to.getProperties(); List<Property> list = new ArrayList<Property>(props.size()); Iterator i = props.entrySet().iterator(); for (; i.hasNext();) { Entry e = (Entry) i.next(); String key = (String) e.getKey(); if (key.startsWith(".")) key = "_" + key; list.add(new Property(key, e.getValue())); } return list.iterator(); } /** * Get the attributes of the element according to the attribute name */ public Iterator getAttributeAxisIterator(Object contextNode,String localName, String namespacePrefix, String namespaceURI) { TestObject to = (TestObject) contextNode; String keyEscaped = localName; if (localName.startsWith("_.")) keyEscaped = localName.substring(1); Object value = to.getProperty(keyEscaped); Property prop = new Property(localName, value); return new SingleObjectIterator(prop); } |
清单 3 显示了如何转化 TestObject 为 XML 中的元素节点来实现 getChildAxisIterator
。需要注意的是当 TestObject 为 DomainTestObject,应用 getTopObjects 来得到子节点。
清单 3. getChildAxisIterator 的实现
/** * Retrieve an Iterator matching the child XPath axis */ public Iterator getChildAxisIterator(Object contextNode) { if (contextNode instanceof TestObject) { TestObject[] children = null; if (contextNode instanceof DomainTestObject) { children = ((DomainTestObject) contextNode).getTopObjects(); } else { children = ((TestObject) contextNode).getChildren(); } List<TestObject> list = Arrays.asList(children); return list.iterator(); } return JaxenConstants.EMPTY_ITERATOR; } /** * Retrieve an Iterator matching the child XPath axis according to the element name */ public Iterator getChildAxisIterator(Object contextNode, String localName, String namespacePrefix, String namespaceURI) throws UnsupportedAxisException { if (contextNode instanceof TestObject) { TestObject[] children = ((TestObject) contextNode).find( SubitemFactory.atChild(".class", localName)); List<TestObject> list = new ArrayList<TestObject>(); for (TestObject c : children) { if (c.getObjectClassName().equals(localName)) { list.add(c); } } return list.iterator(); } return JaxenConstants.EMPTY_ITERATOR; } |
第二步,定义 RFTXPath,用 BaseXPath 作为父类。它是使用 XPath 查询 TestObject 的入口点 , 非常简单 , 如清单 4 所示
package utils; import org.jaxen.BaseXPath; import org.jaxen.JaxenException; public class RFTXPath extends BaseXPath { public RFTXPath(String xpathExpr) throws JaxenException { super(xpathExpr, RFTNavigator.getInstance()); } } |
这样我们就可以使用 XPath 来查询 TestObject 了,如下:
RFTXPath xpath = new RFTXPath(xPath); List results = xpath.selectNodes(testObject); |
在附件的源代码中提供了两个例子来演示如何用 XPath 做识别语言来测试 Java 程序和 Web 程序。demo.JavaXPathDemo 以 RFT 中预制的 ClassicsJavaB 程序作为被测程序(图 2),展示了如何使用 XPath 来识别程序中的 JTextArea 和 JButton,更重要的是读者可以看到使用 XPath 通过子控件来识别一个 JFrame 是多么容易的一件事情,代码见示例 1。
图 2 被测试的 Java 程序
示例 1. 测试 Java 程序
public void testMain(Object[] args) { startApp("ClassicsJavaB"); sleep(3); TestObject frame = javaframe(); //Find the first JTextArea in the first JTabbedPane TestObject to = findByXPath(frame, "javax.swing.JTabbedPane[1]/descendant::javax.swing.JTextArea[1]"); System.out.println("The content of JTextArea:" + to.getProperty("text")); //Find the button named placeOrderButton2 GuiTestObject button = (GuiTestObject)findByXPath(frame, "javax.swing.JButton[@name='placeOrderButton2']"); button.click(); //Find the JFrame which contains a button named ok-orderlogon or cancel-orderlogon to = findByXPathWithRetry(button.getDomain(), "javax.swing.JFrame[javax.swing.JButton[@name='ok-orderlogon' or @name='cancel-orderlogon']]"); System.out.println("The title of JFrame:" + to.getProperty(".captionText")); } |
Web 程序时常会在固定区域 ( 比如一个 DIV) 里动态创建一系列超链接,它们数量不确定,又没有 ID,但是最后一个超链永远指向一个固定页面,如图 3 所示的 HTML 源码中的 today_news DIV。要验证这最后一个超链接,用 XPath 使事情变得相当容易,代码见示例 2。
图 3 被测 Web 页面的源码
示例 2. 测试 Web 程序的代码
public void testMain(Object[] args) { //the current user that logged in String username = "Foo"; startBrowser("file:///C:/WebXPathDemo.html"); sleep(5); GuiTestObject htmlBody = htmlbody(); // Find a hyperlink which text is the current user name TestObject to = findByXPath(htmlBody, "descendant::Html.A[@_.text='" + username + "']"); System.out.println("href property: " + to.getProperty(".href")); // Find the last hyperlink in the <DIV> with ID "today_news" to = findByXPath(htmlBody, "descendant::Html.DIV[@_.id='today_news']/Html.A[last()]"); System.out.println("href property of last hyperlink: " + to.getProperty(".href")); // Directly get property value via XPath String value = stringValueByXPath(htmlBody, "descendant::Html.DIV[@_.id='today_news']/Html.A[last()]/@_.href"); System.out.println("href property of last hyperlink: " + value); } |
另外,在实际应用中我们也总结了几点技巧可供读者参考。
- 为了更加方便的使用 XPath,可以在脚本的 Super Helper Class 里面添加一个静态方法,如清单 5。
清单 5. 在 Super Helper Class 中定义 findByXPath 方法
public class ScriptHelper extends RationalTestScript { public static TestObject findByXPath(TestObject testObject, String xPath) { List results; try { XPath xpath = new RFTXPath(xPath); results = xpath.selectNodes(testObject); } catch (JaxenException e) { throw new RuntimeException(xPath + " is not valid!", e); } if (results.size() == 0) throw new ObjectNotFoundException("Object is not found via " + xPath); if (results.size() > 1) throw new AmbiguousRecognitionException( "Multiple object are found via " + xPath); Object obj = results.get(0); if (!(obj instanceof TestObject)) throw new BadArgumentException("It's not TestObject found via " + xPath); return (TestObject) obj; } } |
- 给 findByXPath 加上自动重试机制,有利于提高稳定性,如清单 6。
清单 6. 在 Super Helper Class 中定义 findByXPathWithRetry 方法
public TestObject findByXPathWithRetry(TestObject testObject, String xPath) { double delay = (Double) getOption(IOptionName.WAIT_FOR_EXISTENCE_DELAY_BETWEEN_RETRIES); long maxTime = (long) (((Double) getOption(IOptionName.MAXIMUM_WAIT_FOR_EXISTENCE)) * 1000); long startTime = System.currentTimeMillis(); while (System.currentTimeMillis() - startTime < maxTime) { try { return findByXPath(testObject, xPath); } catch (ObjectNotFoundException e) { sleep(delay); } catch (RuntimeException e) { throw e; } } throw new ObjectNotFoundException("Object is not found via " + xPath); } |
3. 善用 XPath 内建的逻辑操作符和函数来实现复杂的条件。
4. 由于代码里没有 unregister TestObject, 这可能占用过多的资源。读者应当注意在脚本中适时的调用 unregisterAll() 来释放资源。
5. 将测试对象的 XPath 字符串统一的保存在一个 Java 资源文件中来管理有利于脚本的维护。比如 widgets.properties:
OKButton=org.eclipse.swt.widgets.Shell[@captionText= ’Add Bookmark’]/descendant::org.eclipse.swt.widgets.Button[@text=’OK’] BookMarkNameEdit = org.eclipse.swt.widgets.Shell[@captionText= ’Add Bookmark’]/descendant::org.eclipse.swt.widgets.Text[@text= ’Enter Bookmark name:’] |
如果要支持多语言环境下的测试,仅需要再提供一个对应语言的资源文件,然后使用 java.util.ResourceBundle 来读取即可。比如 widgets_zh_CN.properties:
OKButton=org.eclipse.swt.widgets.Shell[@captionText= ’Add Bookmark’]/descendant::org.eclipse.swt.widgets.Button[@text=’OK’] BookMarkNameEdit= org.eclipse.swt.widgets.Shell[@captionText= ’Add Bookmark’]/descendant::org.eclipse.swt.widgets.Text[@text= ’ Enter Bookmark name:’] |
6. TestObject 的 classname 比较长,定义 XPath 起来比较繁琐,可以在 RFTNavigator 建立一个 Map 将 classname 映射为较短的名字。
7. 可以直接使用 XPath 来读取 TestObject 的属性值,在 Super Helper Class 中定义如清单 7 所示方法,如清单 8 所示使用这些 API。
/** * Get a string value by xpath */ public static String stringValueByXPath(TestObject testObject, String xPath) { try { XPath xpath = new RFTXPath(xPath); return xpath.stringValueOf(testObject); } catch (JaxenException e) { throw new RuntimeException(xPath + " is not valid!", e); } } /** * Get a number value by xpath */ public static Number numberValueByXPath(TestObject testObject, String xPath) { try { XPath xpath = new RFTXPath(xPath); return xpath.numberValueOf(testObject); } catch (JaxenException e) { throw new RuntimeException(xPath + " is not valid!", e); } } /** * Get a Boolean value by xpath */ public static Boolean booleanValueByXPath(TestObject testObject, String xPath) { try { XPath xpath = new RFTXPath(xPath); return xpath.booleanValueOf(testObject); } catch (JaxenException e) { throw new RuntimeException(xPath + " is not valid!", e); } } |
String value = stringValueByXPath(htmlBody, "descendant::Html.DIV[@_.id='w3c_home_recent_blogs']/Html.A[last()]/@_.text"); //The text property value of the hyperlink will be printed in the console System.out.println(value); |
8. 应用 XPath 也可以方便的选出一组符合条件的 TestObject。当需要验证多个动态对象是很方便。
本文所介绍的以 XPath 作为识别表达式,是对 RFT 动态查找 API 的进一步封装,能够适用于各种 RFT 所支持的应用程序类型。使用这种方案能够简化开发测试脚本中使用动态查找的代码量,将识别逻辑从脚本中分离出来,使脚本更加侧重测试步骤和验证的实现。由 于所有的识别信息都在一个字符串中,可以这些字符串统一存为 Java 资源文件。当被测软件的 GUI 发生变化时,也仅仅需要更改一下 XPath 识别表达式,无需修改脚本的 Java 代码,便于维护,同时也能够轻易地进行国际化测试。当然我们也应该看到这种方法不足之处,比如需要额外学习 XPath 语法。