概述
在敏捷测试中 UI 的自动化测试 (一般我们也称这层测试为功能测试或验收测试,本文单指 Web UI 的自动化测试) 虽然没有单元测试那么广为提及,但因为其与最终用户最近,所以基于用户场景的 UI 自动化测试还是有其重要的意义的。使用 UI 自动化测试对产品的关键功能路径进行验证及回归,比起传统的 QA 手工执行 Test case 可以更快地得到反馈,也让发布变得更有信心。
理想状况下,我们应该将所有可以固化下来的 Test case 都自动化起来,而让我们的测试人员进行更有挑战性的探索性测试活动。让机器做已知领域的事儿,让人对未知领域进行探索。不过理想归理想,现实是残酷的。虽然 UI 层的测试距离交付最近,但是成本也最高。编写和维护 UI 自动化测试需要付出比其他自动化测试更高昂的成本,这也是大多数团队放弃 UI 自动化测试的主要原因。相比较系统的其他部分,UI 是一个多变的层,如果 UI 自动化测试没有构建好,即使界面的一个微小改动,整个测试集可能就天崩地裂。这也就是为什么我经常对 team 里其他人说:对于 UI 自动化测试,可维护性必须牢记心头。每当你写下一行测试代码时,你就必须记住你又给公司添加了一笔成本,而且这个成本是持续增长的,如果 review code 的时候发现哪条测试代码维护性不好我会毫不犹豫的删掉。
或许有人觉得这有点小题大作,不就 UI 测试么,有什么难的。定位元素,然后拿到页面元素的值与期望进行比较不就可以了。难就难在定位元素上。一般我们会使用 Selenium WebDriver, Watir, Sahi 等工具驱动浏览器,进行元素定位 (关于这些工具的详细使用可以参见官方文档,后文主要以 Selenium WebDriver 为示例)。这些工具在定位元素上基本上是大同小异:通过 id, name, css, tagName, xpath 等方式定位。这些定位方式,从前到后,一个比一个不靠谱。比如这个 xpath,好不容易写出个 xpath 定位,然后突然有一天前端觉得某个地方不美观,插入一个小东西,马上测试废掉。看着这种没有改变功能也把功能测试搞垮掉的现象是不是欲哭无泪。我有时天真的在想如果页面上每一个元素都有唯一的 id 该多好啊。即使没有唯一的 id,有 name 我也可以接受。不过这一切在遇到 ExtJS 之后都变了。
遭遇 ExtJS
ExtJS 是一个非常霸道的前端框架。使用 ExtJS 后,页面上几乎所有的一切都被 ExtJS 接管。尽管互联网提供给用户的系统鲜有使用 ExtJS,但是对于后台系统使用 ExtJS 确实带来了一些便利。使用 ExtJS 的基本组件就能组装出一个看起来还不错,功能强大的应用。但是 ExtJS 非常霸道,被他接管后页面的生成基本上就是个黑盒子,而为了在各个浏览器的兼容它在各浏览器上生成的 html 还不一样。更可恨的是默认情况下它给元素提供的 id 都是动态生成的。
在刚选择这个 ExtJS 的系统作为我们自动化测试的第一个试点时,我还有点暗暗高兴。比起那些提供给普通用户使用的丰富多彩的前端来说,这些后台系统大多中规中矩,使用 ExtJS 后更是层次分明。而且后台系统 UI 的变动也不会太过于频繁,我想或许这个系统很容易测试吧。
后来我看到同事代码里出现:
webDriver.findElement(By.id("ext-gen-1306"))
我还在想,我们的前端同学真有“创意”,还用这么随机的名字啊。后来厄运来了,我 check out 代码在我这里死活通过不了。Selenium 报告找不到指定元素。不是吧,我可是使用 id 进行定位的啊。通过翻阅 ExtJS 的文档发现,原来类似 ext-gen-xxx 这类 id 都是 ExtJS 动态生成的。好吧,我使用 name 进行定位吧,后来发现很多元素居然没有 name 属性。再来看看 ExtJS 生成的 html,基本上把通过 xpath 进行定位的路给堵死了。要了解 ExtJS 生成的 html,可以去 ExtJS 官方查看一些 Demo。
曙光
阅读 ExtJS 文档我们发现,ExtJS 极其强调它的组件模型。而用 ExtJS 写的前端代码也呈现出很好的结构。因为之前曾从事过 ASP.NET 的开发,我想是不是可以使用 ASP.NET 类似的方式先编写一些小控件类,这些类对 ExtJS 的基本组件进行包装。然后利用这些小控件类组装出一个个页面。这样不仅能把单个元素的定位分散到单个控件类里,而且可以做到极大程度的复用。在传统的 UI 自动化测试中我们使用 Page Object 模式来封装一个个页面,但是对于 ExtJS 来讲页面的粒度还显得过大。如是模仿 ASP.NET 的控件模型,我创建了 Control, Button, TextBox 等一系列基本的控件类。而原来 Page Object 中的 Page 不再使用 WebDriver 直接定位元素了,我们通过这些基本控件组装页面。
实现
在这里我用一个简单的用户登录作为例子:
Control 是我们的基本类型,所有的控件包括页面都从这个类派生。
Control 只提供了很少几个方法:
public abstract class Control { protected WebDriver webDriver; protected Control parent; public Control(WebDriver webDriver) { this.webDriver = webDriver; } public String getQuery() { return StringUtils.EMPTY; } public String getId() { JavascriptExecutor executor = (JavascriptExecutor) webDriver; return (String) executor.executeScript("return " + this.getQuery() + ".id"); } }
在这里 getQuery 是一个非常重要的方法,这在后面会介绍。
public abstract class CompositeControl extends Control { protected List <Control> children; public CompositeControl(WebDriver webDriver) { super(webDriver); children = new ArrayList<Control> (); } public void addChild(Control control) { this.children.add(control); control.parent = this; } }
所有的可以包含其他控件的类型都从 CompositeControl 派生,包括 Page。比如下面的 Window 就是这类元素:
public class Window extends CompositeControl { private String title; public Window(String title,WebDriver webDriver) { super(webDriver); this.title = title; } @Override public String getQuery(){ return String.format("Ext.ComponentQuery.query(\"window[title='%s']\")[0]",title); } }
下面是一个基本控件 Button 的封装:
public class Button extends Control { private String text; public Button(String text, WebDriver webDriver) { super(webDriver); this.text = text; } @Override public String getQuery() { return this.parent.getQuery() + String.format(".query(\"button[text='%s']\")[0]", text); } public void click() { webDriver.findElement(By.id(getId())).click(); } }
ExtJS 提供了一个 query 接口,我们可以利用这个接口传入一些查询表达式查询到页面上的 Ext 控件,而这里的 getQuery 就是每个控件的查询表达式吧。因为页面上的 ExtJS 控件是层次的,所以我们可以利用这种嵌套关系进行精确的定位。
好了,来看看我们的登陆页面如何封装吧:
public class LoginPage extends ExtJSPage{ public LoginPage(WebDriver webDriver){ super(webDriver); } private TextBox txtUserName; private TextBox txtPassword; private Button btnLogin; @Override protected void init(){ txtUserName = new TextBox("userName", webDriver); txtPassword = new TextBox("password", webDriver); btnLogin = new Button(" 登录 ", webDriver); Window win = new Window(" 登陆 ", webDriver); win.addChild(txtUserName); win.addChild(txtPassword); win.addChild(btnLogin); this.addChild(win); } public void login(String userName, String password){ txtUserName.setValue(userName); txtPassword.setValue(password); btnLogin.click(); } }
上面的 TextBox 和 ExtJSPage 没有提供代码,都很简单可以自行进行封装一下 (熟悉 ASP.NET 的同学可能对这里代码有点眼熟)。
按照这种思路,只要我们封装好所有的基本 ExtJS 控件,对于所有的页面我们剩下的工作就是组装的工作了。在完成这些之后,我甚至发现使用 ExtJS 的应用比那些没有使用 ExtJS 的应用更容易进行测试。在这里我们只需要完善我们的基本控件封装就可以让我们的测试更佳稳固,而对于编写测试的人来说只需要集中精力关注 Test case。
下图是目前我们已经实现的一些控件,每个控件实现起来都非常简单,每个控件只需要关注自己的查询表达式和自己应该提供什么方法。但是所有这些基本控件组装起来威力却很大。
扩展
后来我们发现 ExtJS 应用大多有很丰富的表单,一个表单填写页通常有几十个输入项,即使使用这种组装的方式比传统的使用 findElement 一个个定位来得快,但也非常繁琐。我们如是更进一步,建立一些 FormModel(这里的 form model 的意思就是建立数据到表单元素之间的映射),对于这些表单的填写我们甚至不用编写组装代码了,只需要关注那几个 FormModel 就 ok 了。我们还给这些输入控件加上了验证错误,验证默认值等方法。
感谢前同事 @咖啡屋的鼠标 ,是我偷窃了你的创意:ExtJS 的 UI 测试不应该使用 Page Object pattern,应该使用组件模型。才让我可以这么简单的来实现这个测试。
感谢张凯峰对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论