查看完整版本: 应用Selenium和Ruby进行面向领域的Web测试

jackei 2007-7-3 15:46

应用Selenium和Ruby进行面向领域的Web测试

这是一篇非常非常好的文章,其中提出的自动化测试的思想让我对自己以往所设计的自动化测试框架产生了新的想法,并发现可以进一步的改进来提高自动化测试脚本和框架的可读性、可靠性和可维护性。这些思想不仅仅适用于 Selenium 的自动化测试,更可以供其他所有的自动化测试框架所参考。—— Jackei 注




作者 [b]徐昊[/b] 发布于 2007年5月21日 下午10时46分
社区[url=http://www.infoq.com/cn/agile][color=#0000ff]Agile[/color][/url],[url=http://www.infoq.com/cn/ruby][color=#0000ff]Ruby[/color][/url]主题[url=http://www.infoq.com/cn/opensource][color=#0000ff]开放源代码[/color][/url],[url=http://www.infoq.com/cn/unit_testing][color=#0000ff]单元测试[/color][/url],[url=http://www.infoq.com/cn/rubyonrails][color=#0000ff]Ruby on Rails[/color][/url]应用Selenium进行Web测试时,经常会遇到下面的几个麻烦问题:
[list=1][*]大量使用name、id、xpath等页面元素。无论是功能修改、UI重构还是交互性改进都会影响到这些元素,这使得Selenium测试变得非常脆弱。[*]过于细节的页面操作不容易体现出行为的意图,一段时间之后就很难真正把握测试原有的目的了,这使得Selenium测试变得难于维护。[*]对具体数据取值的存在依赖,当个别数据不再合法的时候,测试就会失败,但这样的失败并不能标识功能的缺失,这使得Selenium测试变得脆弱且难以维护。[/list]而这几点直接衍生的结果就是不断地添加新的测试,而极少地去重构、利用原有测试。其实这倒也是正常,单元测试测试写多了,也有会有这样的问题。不过比较要命的是,Selenium的执行速度比较慢(相对单元测试),随着测试逐渐的增多,运行时间会逐渐增加到不可忍受的程度。一组意图不明而且难以维护的Selenium测试,可以很轻松地在每次构建(Build)的时候杀掉40分钟甚至2个小时的时间,我就有曾有花2个小时坐在电脑前面等待450个 Selenium测试运行通过的悲惨经历。因此合理有效地规划Selenium测试就显得格外的迫切和重要了。而目前比较行之有效的办法,往大了说,可以叫基于领域的Web测试(Domain Based Web Testing),具体来讲,就是Page Object Pattern。
Page Object Pattern里有四个基本概念:Driver、Page、Navigator和Shortcut等。Driver是测试真正的实现机制,比如 Selenium,比如Watir,比如HttpUnit。它们懂得如何去真正执行一个Web行为,通常包含像Click、Select、Type等这样的表示具体行为的方法;Page是对一个具体页面的封装,它们了解页面的结构,知道诸如id、name、class和xpath这类实现细节,并描述用户可以在其上进行何种操作;Navigator则代表了URL,表示一些不经页面操作的直接跳转;最后Shortcut就是helper方法了,需要看具体的需要而定。下面来看一个超级简单的例子——测试登录页面。
1. Page Object假设我们使用一个单独的登录页面进行登录,那么可能会将登录的操作封装在一个名为LoginPage的page object里:
[size=13px][color=#00ff]class[/color][color=#000] LoginPage
  [/color][color=#00ff]def[/color][color=#000] initialize driver
    @driver [/color][color=#000]=[/color][color=#000] driver
  end
  
  [/color][color=#00ff]def[/color][color=#000] login_as user
    @driver.type [/color][color=#8000]'[/color][color=#8000]id=[img]http://www.cnblogs.com/Images/dot.gif[/img][/color][color=#8000]'[/color][color=#000], user[:name]
    @driver.type [/color][color=#8000]'[/color][color=#8000]xpath=[img]http://www.cnblogs.com/Images/dot.gif[/img][/color][color=#8000]'[/color][color=#000], user[:password]
    @driver.click [/color][color=#8000]'[/color][color=#8000]name=[img]http://www.cnblogs.com/Images/dot.gif[/img][/color][color=#8000]'[/color][color=#000]
    @driver.wait_for_page_to_load
  end
end[/color][/size]

login_as是一个具有业务含义的页面行为。在login_as方法中,page object负责通过依靠id、xpath、name等信息完成登录操作。在测试中,我们可以这样来使用这个page object:
[size=13px][color=#000]page [/color][color=#000]=[/color][color=#000] LoginPage.new $selenium
page.login_as :name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][/size]

不过既然用了Ruby,总要用一些ruby sugar吧,我们定义一个on方法来表达页面操作的环境:
[size=13px][color=#00ff]def[/color][color=#000] on page_type, [/color][color=#000]&[/color][color=#000]block
  page [/color][color=#000]=[/color][color=#000] page_type.new $selenium
  page.instance_eval [/color][color=#000]&[/color][color=#000]block [/color][color=#00ff]if[/color][color=#000] block_given?
end[/color][/size]

之后我们就可以使用page object的类名常量和block描述在某个特定页面上操作了:
[size=13px][color=#000]on LoginPage do
  login_as :name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][color=#000]
end[/color][/size]

除了行为方法之外,我们还需要在page object上定义一些获取页面信息的方法,比如获取登录页面的欢迎词的方法:
[size=13px][color=#00ff]def[/color][color=#000] welcome_message
  @driver.get_text [/color][color=#8000]'[/color][color=#8000]xpath=[img]http://www.cnblogs.com/Images/dot.gif[/img][/color][color=#8000]'[/color][color=#000]
end[/color][/size]

这样测试也可表达得更生动一些:
[size=13px][color=#000]on LoginPage do
  assert_equal [/color][color=#8000]'[/color][color=#8000]Welcome![/color][color=#8000]'[/color][color=#000], welcome_message
  login_as :name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][color=#000]
end[/color][/size]

当你把所有的页面都用Page Object封装了之后,就有效地分离了测试和页面结构的耦合。在测试中,只需使用诸如login_as和add_product_to_cart这样的业务行为,而不必依靠像id、name等这些具体且易变的页面元素了。当这些页面元素发生变化时,只需修改相应的page object就可以了,而原有测试基本不需要太大或太多的改动。
2. Assertation只有行为还构不成测试,我们还要判断行为结果,并进行一些断言。简单回顾一下上面的例子,会发现还有一些很重要的问题没有解决:我怎么判断登录成功了呢?我如何才能知道真的是处在登录页面了呢?如果我调用下面的代码会怎样呢?
[size=13px][color=#000]$selenium.open url_of_any_page_but_not_login
on LoginPage {[img]http://www.cnblogs.com/Images/dot.gif[/img]}[/color][/size]

因此我们还需要向page object增加一些断言性方法。至少,每个页面都应该有一个方法用于判断是否真正地达到了这个页面,如果不处在这个页面中的话,就不能进行任何的业务行为。下面修改LoginPage使之包含这样一个方法:
[size=13px][color=#000]LoginPage.class_eval do
  include Test::Unit::Asseration
  [/color][color=#00ff]def[/color][color=#000] visible?
    @driver.is_text_present([img]http://www.cnblogs.com/Images/dot.gif[/img]) [/color][color=#000]&&[/color][color=#000] @driver.get_location [/color][color=#000]==[/color][color=#000] [img]http://www.cnblogs.com/Images/dot.gif[/img]
  end
end[/color][/size]

在visible?方法中,我们通过对一些特定的页面元素(比如URL地址,特定的UI结构或元素)进行判断,从而可以得之是否真正地处在某个页面上。而我们目前表达测试的基本结构是由on方法来完成,我们也就顺理成章地在on方法中增加一个断言,来判断是否真的处在某个页面上,如果不处在这个页面则不进行任何的业务操作:
[size=13px][color=#00ff]def[/color][color=#000] on page_type, [/color][color=#000]&[/color][color=#000]block
  page [/color][color=#000]=[/color][color=#000] page_type.new $selenium
  [/color][color=#00ff]assert[/color][color=#000] page.visible?, [/color][color=#8000]"[/color][color=#8000]not on #{page_type}[/color][color=#8000]"[/color][color=#000]
  page.instance_eval [/color][color=#000]&[/color][color=#000]block [/color][color=#00ff]if[/color][color=#000] block_given?
  page
end[/color][/size]

这个方法神秘地返回了page对象,这里是一个比较取巧的技巧。实际上,我们只想利用page != nil这个事实来断言页面的流转,比如,下面的代码描述登录成功的页面流转过程:
[size=13px][color=#000]on LoginPage do
  assert_equal [/color][color=#8000]'[/color][color=#8000]Welcome![/color][color=#8000]'[/color][color=#000], welcome_message
  login_as :name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]xxx[/color][color=#8000]'[/color][color=#000]
end
[/color][color=#00ff]assert[/color][color=#000] on WelcomeRegisteredUserPage[/color][/size]

除了这个基本断言之外,我们还可以定义一些业务相关的断言,比如在购物车页面里,我们可以定义一个判断购物车是否为空的断言:
[size=13px][color=#00ff]def[/color][color=#000] cart_empty?
  @driver.get_text([/color][color=#8000]'[/color][color=#8000]xpath=[img]http://www.cnblogs.com/Images/dot.gif[/img][/color][color=#8000]'[/color][color=#000]) [/color][color=#000]==[/color]
[color=#8000]'[/color][color=#8000]Shopping Cart(0)[/color][color=#8000]'[/color][color=#000]
end[/color][/size]

需要注意的是,虽然我们在page object里引入了Test::Unit::Asseration模块,但是并没有在断言方法里使用任何assert*方法。这是因为,概念上来讲 page object并不是测试。使之包含一些真正的断言,一则概念混乱,二则容易使page object变成针对某些场景的test helper,不利于以后测试的维护,因此我们往往倾向于将断言方法实现为一个普通的返回值为boolean的方法。
3. Test Data测试意图的体现不仅仅是在行为的描述上,同样还有测试数据,比如如下两段代码:
[size=13px][color=#000]on LoginPage do
  login_as :name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]userA[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]password[/color][color=#8000]'[/color][color=#000]
end
[/color][color=#00ff]assert[/color][color=#000] on WelcomeRegisteredUserPage

registered_user [/color][color=#000]=[/color][color=#000] {:name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]userA[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]password[/color][color=#8000]'[/color][color=#000]}
on LoginPage do
  login_as registered_user
end
[/color][color=#00ff]assert[/color][color=#000] on WelcomeRegisteredUserPage[/color][/size]

测试的是同一个东西,但是显然第二个测试更好的体现了测试意图:使用一个已注册的用户登录,应该进入欢迎页面。我们看这个测试的时候,往往不会关心用户名啊密码啊具体是什么,我们关心它们表达了怎样的测试案例。我们可以通过DataFixture来实现这一点:
[size=13px][color=#000]module DataFixture
  USER_A [/color][color=#000]=[/color][color=#000] {:name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]userA[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]password[/color][color=#8000]'[/color][color=#000]}
  USER_B [/color][color=#000]=[/color][color=#000] {:name [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]userB[/color][color=#8000]'[/color][color=#000], :password [/color][color=#000]=>[/color]
[color=#8000]'[/color][color=#8000]password[/color][color=#8000]'[/color][color=#000]}

  [/color][color=#00ff]def[/color][color=#000] get_user identifier
    case identifier
    when :registered then [/color][color=#00ff]return[/color][color=#000] USER_A
    when :not_registered then [/color][color=#00ff]return[/color][color=#000] USER_B
    end
  end
end[/color][/size]

在这里,我们将测试案例和具体数据做了一个对应:userA是注册过的用户,而userB是没注册的用户。当有一天,我们需要将登录用户名改为邮箱的时候,只需要修改DataFixture模块就可以了,而不必修改相应的测试:
[size=13px][color=#000]include DataFixtureDat

user [/color][color=#000]=[/color][color=#000] get_user :registered
on LoginPage do
  login_as user
end
[/color][color=#00ff]assert[/color][color=#000] on WelcomeRegisteredUserPage[/color][/size]

当然,在更复杂的测试中,DataFixture同样可以使用真实的数据库或是Rails Fixture来完成这样的对应,但是总体的目的就是使测试和测试数据有效性的耦合分离:
[size=13px][color=#00ff]def[/color][color=#000] get_user identifier
  case identifier
  when :registered then [/color][color=#00ff]return[/color][color=#000] User.find [/color][color=#8000]'[/color][color=#8000][img]http://www.cnblogs.com/Images/dot.gif[/img].[/color][color=#8000]'[/color][color=#000]  
  end
end[/color][/size]

4.Navigator与界面元素类似,URL也是一类易变且难以表达意图的元素,因此我们可以使用Navigator使之与测试解耦。具体做法和Test Data相似,这里就不赘述了,下面是一个例子:
[size=13px][color=#000]navigate_to detail_page_for @product
on ProductDetailPage do
  [img]http://www.cnblogs.com/Images/dot.gif[/img].
end[/color][/size]

5. Shortcut前面我们已经有了一个很好的基础,将Selenium测试与各种脆弱且意图不明的元素分离开了,那么最后shortcut不过是在蛋糕上面最漂亮的奶油罢了——定义具有漂亮语法的helper:
[size=13px][color=#00ff]def[/color][color=#000] should_login_successfully user
  on LoginPage do
    assert_equal [/color][color=#8000]'[/color][color=#8000]Welcome![/color][color=#8000]'[/color][color=#000], welcome_message
    login_as user
  end
  [/color][color=#00ff]assert[/color][color=#000] on WelcomeRegisteredUserPage
end[/color][/size]

然后是另外一个magic方法:
[size=13px][color=#00ff]def[/color][color=#000] given identifer
  words [/color][color=#000]=[/color][color=#000] identifier.to_s.split [/color][color=#8000]'[/color][color=#8000]_[/color][color=#8000]'[/color][color=#000]
  eval [/color][color=#8000]"[/color][color=#8000]get_#{words.last} :#{words[0..-2].join '_'}[/color][color=#8000]"[/color][color=#000]
end[/color][/size]

之前的测试就可以被改写为:
[size=13px][color=#00ff]def[/color][color=#000] test_should_xxxx
  should_login_successfully given :registered_user
end[/color][/size]

这是一种结论性的shortcut描述,我们还可以有更behaviour的写法:
[size=13px][color=#00ff]def[/color][color=#000] login_on page_type
  on page_type do
    assert_equal [/color][color=#8000]'[/color][color=#8000]Welcome![/color][color=#8000]'[/color][color=#000], welcome_message
    login_as @user
  end
end

[/color][color=#00ff]def[/color][color=#000] login_successfully
  on WelcomeRegisteredUserPage
end

[/color][color=#00ff]def[/color][color=#000] given identifer
  words [/color][color=#000]=[/color][color=#000] identifier.to_s.split [/color][color=#8000]'[/color][color=#8000]_[/color][color=#8000]'[/color][color=#000]
  eval [/color][color=#8000]"[/color][color=#8000]@#{words.last} = get_#{words.last} :#{words[0..-2].join '_'}[/color][color=#8000]"[/color][color=#000]
end[/color][/size]

最后,测试就会变成类似验收条件的样子:
[size=13px][color=#00ff]def[/color][color=#000] test_should_xxx
  given :registered_user
  login_on LoginPage
  [/color][color=#00ff]assert[/color][color=#000] login_successfully
end[/color][/size]

总之shortcut是一个无关好坏,只关乎想象力的东西,尽情挥洒Ruby DSL吧!
结论Selenium是一个让人又爱又恨的东西,错误地使用Selenium会给整个敏捷团队的开发节奏带来灾难性的影响。不过值得庆幸的是正确地使用Selenium的原则也是相当的简单:
[list=1][*]通过将脆弱易变的页面元素和测试分离开,使得页面的变化不会对测试产生太大的影响。[*]明确指定测试数据的意图,不在测试用使用任何具体的数据。[*]尽一切可能,明确地表达出测试的意图,使测试易于理解。[/list]当然,除了遵循这几个基本原则之外,使用page object或其他domain based web testing技术是个不错的选择。它们将会帮助你更容易地控制Selenium测试的规模,更好地平衡覆盖率和执行效率,从而更加有效地交付高质量的Web项目。
此文中涉及的都是我最近几周以来对Selenium测试进行重构时所采用的真实技术。感谢Nick Drew帮助我清晰地划分了Driver、 Page、Nagivator和Shortcut的层次关系,它们构成我整个实践的基石;感谢Chris Leishman,在和他结对编程的过程中,他帮助我锤炼了Ruby DSL;还有Mark Ryall和Abhi,是他们第一次在项目中引入了Test Data Fixture,使得所有人的工作都变得简单起来。
[b]作者简介:[/b][b]徐昊[/b],ThoughtWorks咨询师和敏捷过程教练;[url=http://www.bjug.org/][color=#0000ff]BJUG[/color][/url](Beijing Java User Group)和[url=http://groups.google.com/group/agilechina][color=#0000ff]AgileChina[/color][/url]主要创始人之一;RSSer(Ruby,Smalltalk & Scheme)。目前主要致力于研究编译理论和推广DSL(Domain Specified Language)在实际项目中的应用。他的博客地址是:[url=http://www.blogjava.net/raimundox][color=#0000ff]http://www.blogjava.net/raimundox[/color][/url]。

jackei 2007-7-19 15:29

呵呵,看来是曲高和寡啊 ^_^

yuxiao 2007-7-24 11:06

呵呵,我没看懂啊,我会继续努力的

韩诺 2007-9-13 16:26

我也看不懂,继续努力!
页: [1]
查看完整版本: 应用Selenium和Ruby进行面向领域的Web测试