原文链接:https://www.lucidchart.com/techblog/2015/07/21/selenium-7-things-you-need-to-know-2/
译者的话:
本文的作者就职于Lucid,如文中所述他们有着一套比较成熟的基于selenium的自动化测试工具。这得益于他们对于自动化测试的重视,因为只有自动化测试才能很好地保证敏捷开发的软件质量。目前国内已经渐渐开始重视自动化测试,但是由于起步较晚,大多数公司的软件测试依旧依赖于人力的叠加,这也使得软件测试工程师在大多数时候被认为是IT行业内的二等公民。事实上,想成为一名优秀的软件测试工程师,要求有着很强的功底,需要掌握很多技能。只有每个软件测试工程师都认识到这点,才能更好地担负起职责,整个行业才能发展。
selenium是一套最近比较火的开源自动化测试框架,比传统的自动化测试工具有着更高的灵活性。灵活性意味着你可以按自己的想法去使用它,但同时也意味着你需要掌握一定的技巧去驾驭它。作者在文中介绍了七个需要注意的地方,其中有些在我的工作中也使用到了,有些跟我用的方式不同,还有一些我没有用。本文仅仅是一种参考。《孙子兵法》中说,兵无常势,水无常形。我认为做技术是一样的,怎么用取决于你的需求。技术是用来提高效率、保证质量、降低成本的,不是拿来吹嘘的。否则就会成为孔乙己,“你知道茴的四种写法吗”。
哈哈~
Selenium是测试工程师工具库中的一件强大的工具。然而不幸的是,如果不能很好的驾驭它,它有可能会消耗你很大精力去编写测试用例,维护起来极其艰难,甚至会取得适得其反的效果。
起初,我厂有一个涵盖了300个测试用例的测试集,它们由40个不同的开发人员编写。每次我们run这些用例,总有六七十个会报错。为了保险起见,我们让一位同事专门负责统计在这些报错中哪些用例正确地发现了错误,哪些是误报。这工作几乎占据了他全部的时间。所以当他发现那些误报的用例阻塞了自动构建流程,并且这些用例难以修复的时候,他就会将它们从自动构建中注掉。久而久之,这些用例慢慢地就被遗忘了。
有次我们新增了一个feature,在我们的测试过程中居然没有发现它有个bug,直到这个feature上线两周后,这个bug才被发现。我们都惊呆了,居然会让这个bug通过开发环境,躲过测试环境的抓捕,顺利进入生产环境。我准备一探究竟,结果不出我所料,我一个月前为这个feature写的用例被人注掉了!!尼玛,我心中简直有千万只草泥马蹦过。但与此同时,我陷入了深深的思考,到底怎么样才能让我们的selenium测试用例变得可靠、可扩展、容易维护呢?
不仅是我,我们整个团队都致力于寻找答案的工作中。经过持之以恒的努力,工作取得了不错的效果。我们在把误报率降低到百分之一以下的同时,把测试用例的数量增加到原来的两倍。我们会定期的在开发过程中回归分析,新增测试用例去覆盖我们产品中的新feature。selenium在我们的版本发布中扮演了一个极其重要的角色,下面就就让我介绍一下我们在使用selenium过程中最需要注意的七件事。
我们发现很多时候编写测试用例所使用的时间和开发修改一个bug或新增一个feature的时间一样,基于这点,我们需要使得测试用例更容易编写。我们提供的解决方案是创建测试用例的Application User和Application Driver。
所谓ApplicationUser其实就是selenium在后台操作的代理。对我们来说就是创建一个新的 Lucidchart或者 Lucidpress的用户,发起一次订阅、创建一个文档。它包含了一些对于测试场景的准备操作。同时这个类也可以包含调用后端service的通道,比如说添加团队成员,下载图片或者字体。使用这个类使得改变订阅等级变得特别容易。下面就给出一个测试开发人员使用application User的示例:
classEditorPerformanceTest extends LucidSpec {
val user = new ChartUser
override def beforeAll() {
user.login()
user.createDocument()
}
override def afterAll() {
user.finished()
}
在这种情况下,所有的设置都简化成了两个方法调用,不再需要写额外的启动操作。在测试的最后,所有的关闭操作都由结束方法执行。总而言之,使用User类,我们使得测试开发人员更容易地创建测试用例,把主要精力放在验证bug和测试新feature上。
selenium的api很容易让人感到绝望。因为单单获取一个元素就可以用差不多二十种不同的方法实现。去模拟浏览器各种操作的方法更是不计其数,拖拽和释放、左击和右击、使用滚轮、输入等等等等。为了简化这些操作,不必让所有的测试开发人员都十分熟悉整个webDriver api文档,我们创建了一个driver去简化大多数相似的操作。也就是Application Driver,它继承自WebDriver,并添加了部分selenium其它的操作类。以此为基础,我们又添加大多数通用的操作,比如点击元素,执行脚本,拖拽释放网页元素。这个类差不多下面这个UML图这样的结构,包含了一些非常简单的方法
defdragAndDrop
(cssFrom
:String,
cssTo
:String)
{
val
elem1
=getElementByCss
(cssFrom
)
val
elem2
=getElementByCss
(cssTo
)
actions
.dragAndDrop
(elem1
,elem2
)
}
defcontextClickByCss
(css
:String)
actions
.contextClick
(getElementByCss
(css
))
}
当测试开发人员需要更多复杂操作的时候,他们依然可以采用webDriver和selenium 原生的操作类。但是对我们大多数测试而言,Lucid Driver完全够用了。采用了这个方法还有另外一个好处,就是使得在编写测试用例的时候更容易去调试了。因为所有的测试开发人员使用的是同样的方法,而不是他们使用各自在api文档里找到的不同方法去实现完全相同的功能.
在dom结构中去定位一个元素是编写selenium测试用例中最具挑战的一个部分。给标签添加ID可以使得某个页面元素在整个应用中拥有一个独一无二的标识。在我们最初的测试用例中,我们使用xpath、classPath、css选择器去定位重要的页面元素。然而,当我们调整了页面结构,或者是简单的改变了原来css class的名字之后,发现原先的测试用例已经不能用了,需要修改在测试用例重写element locate。但是如果给每一个页面元素都赋一个ID,无论DOM结构怎么变、样式怎么变,我们都能很轻松地定位到页面元素。
下面是一个典型的例子,针对这个特殊的feature有4个测试集,总的测试用例在30个左右。另外还有20-30个测试用例是依赖这个feature的。由于我们给页面元素添加了ID,这些测试用例几乎就不需要怎么维护了。这些测试用例可以轻松找到找到任何他想要的页面元素。可以想象,如果不使用ID的话,对这个feature个小小的页面调整,都可能使得上述的测试用例都要修改一遍。
页面对象模式使得测试用例的可扩展性变得更好。页面对象模式可以使得测试用例的结构变得简单明了,因为每个页面对象中包含了该页面的所有操作。举个例子,登录页面知道怎么确认登录,点击忘记密码,用google账号注册等等。所有的测试用例都可以使用页面的操作(即调用页面对象的方法)。因为测试用例是由不同的测试开发人员编写的,在我们的产品中,你可以有很多不同的方法去实现相同的操作。就好像在Lucidchart的文档页面上选择一个文档。我们可以写6种css 路径去定位一个文档,有三种方法去点击一个文档。我们有50个用例会用到上述的操作。假如没有使用页面对象模式,维护这五十个用例对我们来说将会是一场噩梦。下面的例子就是我们文档页面的页面对象。
object DocsList extends RetryHelper with MainMenu with Page {
val actionsPanel = new ActionsPanel
val fileBrowser = new FileBrowser
val fileTree = new FileTree
val sharingPanel = new SharingPanel
val invitationPanel = new InvitationPanel
由于我们要执行很多操作,我们把它拆分成了多个子页面对象。这些子的页面对象组合起来就是一个完整的页面。
每一个小的页面对象都包含了它代表的页面中的所有操作,举个例子,在文件浏览的子页面对象,我们有个方法用来点击“创建文档”按钮,选择一个文档模板,然后校验文档库中文档数的用例。代码如下:
def clickCreateDocument(implicit user: LucidUser) {
doWithRetry() {
user.clickElement("new-document-button")
}
}
def selectDocument(fileNum: Int=0)(implicit user: LucidUser) {
doWithRetry() {
user.driver.getElements(docIconCss)(fileNum).click()
}
}
def numberOfDocsEquals(numberOfDocs: Int)(implicit user: LucidUser) : Boolean ={
predicateWithRetry(WebUser.longWaitTime *5, WebUser.waitTime) {
numberOfDocuments == numberOfDocs
}
}
这使得我们把原先晦涩难懂的测试用例变的简洁明了,任何人都可以轻松地读懂它。
因为使用了页面对象模型,我们的测试框架很容易维护和扩展。每当一个feature被更新的时候,我们要做的仅仅是去更新页面对象,而测试开发人员很清楚需要去修改哪一个部分。当我们需要编写测试用例去覆盖新场景的时候,我们只需要把以前写过的功能点重新组合起来就可以了。原先需要两三个小时的活现在只需要10分钟就可以搞定了!
误报通常是我们在使用selenium的最头疼的问题,这使得很难把selenium测试用例加入到自动构建中。有些构建是必须要成功的,如果失败将会阻塞整个发布流程。然而并没有人会乐意处理构建中的报错。在Lucid,如何避免误报是我们让selenium变得有价值的头等大事。我们的解决方案是在测试步骤和测试集中都加入重试机制。
产生误报最大原因是selenium在页面加载完成之前就开始请求页面资源,举个栗子~selenium打开了点击按钮去打开一个弹窗。在js还没有执行完成,弹窗还没有打开的时候,selenium就已经要去使用它。这当然就会各种报错了,element not found exception、element notclickable .......
解决这个问题最原始的方法就是加等待了。什么?还是找不到element?等待时间加长!这种方法用的多了,我们的代码看上去就很蠢。不单单是蠢,很多时候浏览器早就加载完了,然而测试代码还在等待中。本来selenium执行用例的速度就比较慢,现在更是雪上加霜。
那么怎么改善这一现状呢?我们首先想到的是使用selenium自带的解决方案(FluentWait,Explicit Waits, and Implicit Waits),但在我们的应用中,这并不能满足所有的需求,那么就只有自己动手了。
/**
* Try and take an action until it returns a value or we timeout
* @param maxWaitMillis the maximum amount of time to keep trying for in milliseconds
* @param pollIntervalMillis the amount of time to wait between retries in milliseconds
* @param callback a function that gets a value
* @tparam A the type of the callback
* @return whatever the callback returns, or throws an exception
*/
@annotation.tailrec
private def retry[A](maxWaitMillis: Long, pollIntervalMillis: Long)(callback: => A): A = {
val start = System.currentTimeMillis
Try {
callback
} match {
case Success(value) => value
case Failure(thrown) => {
val timeForTest = System.currentTimeMillis - start
val maxTimeToSleep = Math.min(maxWaitMillis - pollIntervalMillis, pollIntervalMillis)
val timeLeftToSleep = maxTimeToSleep - timeForTest
if (maxTimeToSleep <= 0) { throw thrown } else { if (timeLeftToSleep > 0) {
Thread.sleep(timeLeftToSleep)
}
retry(maxWaitMillis - pollIntervalMillis, pollIntervalMillis)(callback)
}
}
}
}
我们自己写的重写代码以一个递归算法为基础,包含最大等待时间和轮询时间这两个参数。这个方法将会不停地执行下去,直到方法执行成功或者超过我们设置的最大等待时间。根据不同的情况,我们以这个方法为原型另外实现了三个方法[4]
1、需要在重试方法中获取返回值
def
numberOfChildren(implicit user
:LucidUser):
Int
=
{
getWithRetry()
{
user
.driver
.getCssElement(visibleCss
).children
.size
}
}
2、不需要重试方法提供返回值
def
clickFillColorWell(implicit user
:LucidUser)
{
doWithRetry()
{
user
.clickElementByCss("#fill-colorwell-color-well-wrapper")
}
3、通过返回一个Boolean值判断执行结果,发现失败则继续重试
def
onPage(implicit user
:LucidUser):
Boolean
=
{
predicateWithRetry()
{
user
.driver
.getCurrentUrl
.contains(pageUrl
)
}
}
通过这三个方法,我们大致可以把误报率降到2%以下。我们所有的方法默认最大等待时间为1秒,轮询时间为50毫秒。所以重试方法对测试用例执行速度的影响是微乎其微的。在我们最好的一次实践中,我们把一个测试用例的误报率从10%降低到0,并且执行时间从原先的45秒降低到33秒。
在提升测试用例可靠性的努力中,我们最后所做的工作就是测试重试。所谓测试重试就是把执行失败的用例重新执行。只要在随后的重试中有一次成功,我们就认为此项测试是通过的。我们敢这么做,是因为假使这个用例真的是因为应用中的bug才执行失败的,那么不管你执行多少次肯定都是错误的。
在Lucid,我们尽可能保守地使用测试重试。频繁的误报可能意味这测试用例写的太烂了。但有时候确实没有必要耗费很大的精力去使一个测试用例变得很健壮。对我们来说,我们不会在依赖第三方服务的用例上耗费过多的精力。我们可以把这些测试用例写的更好,但去修正这些只是偶尔发生的误报并没有太大的价值。重试并不能修正一个测试用例,却能消除误报对于测试报告的影响。
我第一次使用selenium的时候是非常痛苦的,我的测试用例经常会莫名其妙地失败。然而修改他们令人感到无聊而烦躁。测试用例很多都是重复的并且很难写。并不是只有我一个人有这种感觉,整个公司的测试开发人员都这么认为。在新feature要上线之前编写selenium测试用例让我们感到头疼。
我厂旨在编写一套足够优秀的selenium测试用例,其中创建一个可靠、可伸缩、可维护的测试框架只是第一步。此外,我们还添加一些非常有趣的的测试用例。有一个测试开发人员设计了一套从我们的主画板中截取屏幕的截图,你可以在 Amazon’sS3 service找到它。我们随后将会把它集成到屏幕截图比较工具中做一些图片比较的测试。另一个有意思的测试集主要用在文档合作编写上。它将在测试几个用户之间的聊天和实时协作中展现出它的价值。当然我厂还有更多牛逼的测试用例哦~
通过使用selenium测试集,我们在开发的过程中,每周都能完成好几次回归测试。在我们的测试结果中,只有1%的误报率。我们已经见证了过去几个月在使用上述方法之后,我们的测试集在可靠性、可维护性、可伸缩性上取得的长足进步。在过去的几周里,我们的selenium测试用例的数量每天都在增长,在我们提高软件质量的工作中扮演着越来越重要的角色。