速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

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

  • 2007-05-21
  • 本文字数:4478 字

    阅读完需:约 15 分钟

应用 Selenium 进行 Web 测试时,经常会遇到下面的几个麻烦问题:

  1. 大量使用 name、id、xpath 等页面元素。无论是功能修改、UI 重构还是交互性改进都会影响到这些元素,这使得 Selenium 测试变得非常脆弱。
  2. 过于细节的页面操作不容易体现出行为的意图,一段时间之后就很难真正把握测试原有的目的了,这使得 Selenium 测试变得难于维护。
  3. 对具体数据取值的存在依赖,当个别数据不再合法的时候,测试就会失败,但这样的失败并不能标识功能的缺失,这使得 Selenium 测试变得脆弱且难以维护。

而这几点直接衍生的结果就是不断地添加新的测试,而极少地去重构、利用原有测试。其实这倒也是正常,单元测试测试写多了,也有会有这样的问题。不过比较要命的是,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 里:

复制代码
class LoginPage
def initialize driver
@driver = driver
end
def login_as user
@driver.type 'id=...', user[:name]
@driver.type 'xpath=...', user[:password]
@driver.click 'name=...'
@driver.wait_for_page_to_load
end
end

login_as 是一个具有业务含义的页面行为。在 login_as 方法中,page object 负责通过依靠 id、xpath、name 等信息完成登录操作。在测试中,我们可以这样来使用这个 page object:

复制代码
page = LoginPage.new $selenium
page.login_as :name => 'xxx', :password => 'xxx'

不过既然用了 Ruby,总要用一些 ruby sugar 吧,我们定义一个 on 方法来表达页面操作的环境:

复制代码
def on page_type, &block
page = page_type.new $selenium
page.instance_eval &block if block_given?
end

之后我们就可以使用 page object 的类名常量和 block 描述在某个特定页面上操作了:

复制代码
on LoginPage do
login_as :name => 'xxx', :password => 'xxx'
end

除了行为方法之外,我们还需要在 page object 上定义一些获取页面信息的方法,比如获取登录页面的欢迎词的方法:

复制代码
def welcome_message
@driver.get_text 'xpath=...'
end

这样测试也可表达得更生动一些:

复制代码
on LoginPage do
assert_equal 'Welcome!', welcome_message
login_as :name => 'xxx', :password => 'xxx'
end

当你把所有的页面都用 Page Object 封装了之后,就有效地分离了测试和页面结构的耦合。在测试中,只需使用诸如 login_as 和 add_product_to_cart 这样的业务行为,而不必依靠像 id、name 等这些具体且易变的页面元素了。当这些页面元素发生变化时,只需修改相应的 page object 就可以了,而原有测试基本不需要太大或太多的改动。

2. Assertation

只有行为还构不成测试,我们还要判断行为结果,并进行一些断言。简单回顾一下上面的例子,会发现还有一些很重要的问题没有解决:我怎么判断登录成功了呢?我如何才能知道真的是处在登录页面了呢?如果我调用下面的代码会怎样呢?

复制代码
$selenium.open url_of_any_page_but_not_login
on LoginPage {...}

因此我们还需要向 page object 增加一些断言性方法。至少,每个页面都应该有一个方法用于判断是否真正地达到了这个页面,如果不处在这个页面中的话,就不能进行任何的业务行为。下面修改 LoginPage 使之包含这样一个方法:

复制代码
LoginPage.class_eval do
include Test::Unit::Asseration
def visible?
@driver.is_text_present(...) && @driver.get_location == ...
end
end

在 visible? 方法中,我们通过对一些特定的页面元素(比如 URL 地址,特定的 UI 结构或元素)进行判断,从而可以得之是否真正地处在某个页面上。而我们目前表达测试的基本结构是由 on 方法来完成,我们也就顺理成章地在 on 方法中增加一个断言,来判断是否真的处在某个页面上,如果不处在这个页面则不进行任何的业务操作:

复制代码
def on page_type, &block
page = page_type.new $selenium
assert page.visible?, "not on #{page_type}"
page.instance_eval &block if block_given?
page
end

这个方法神秘地返回了 page 对象,这里是一个比较取巧的技巧。实际上,我们只想利用 page != nil 这个事实来断言页面的流转,比如,下面的代码描述登录成功的页面流转过程:

复制代码
on LoginPage do
assert_equal 'Welcome!', welcome_message
login_as :name => 'xxx', :password => 'xxx'
end
assert on WelcomeRegisteredUserPage

除了这个基本断言之外,我们还可以定义一些业务相关的断言,比如在购物车页面里,我们可以定义一个判断购物车是否为空的断言:

复制代码
def cart_empty?
@driver.get_text('xpath=...') == 'Shopping Cart(0)'
end

需要注意的是,虽然我们在 page object 里引入了 Test::Unit::Asseration 模块,但是并没有在断言方法里使用任何 assert* 方法。这是因为,概念上来讲 page object 并不是测试。使之包含一些真正的断言,一则概念混乱,二则容易使 page object 变成针对某些场景的 test helper,不利于以后测试的维护,因此我们往往倾向于将断言方法实现为一个普通的返回值为 boolean 的方法。

3. Test Data

测试意图的体现不仅仅是在行为的描述上,同样还有测试数据,比如如下两段代码:

复制代码
on LoginPage do
login_as :name => 'userA', :password => 'password'
end
assert on WelcomeRegisteredUserPage
registered_user = {:name => 'userA', :password => 'password'}
on LoginPage do
login_as registered_user
end
assert on WelcomeRegisteredUserPage

测试的是同一个东西,但是显然第二个测试更好的体现了测试意图:使用一个已注册的用户登录,应该进入欢迎页面。我们看这个测试的时候,往往不会关心用户名啊密码啊具体是什么,我们关心它们表达了怎样的测试案例。我们可以通过 DataFixture 来实现这一点:

复制代码
module DataFixture
USER_A = {:name => 'userA', :password => 'password'}
USER_B = {:name => 'userB', :password => 'password'}
def get_user identifier
case identifier
when :registered then return USER_A
when :not_registered then return USER_B
end
end
end

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

复制代码
include DataFixtureDat
user = get_user :registered
on LoginPage do
login_as user
end
assert on WelcomeRegisteredUserPage

当然,在更复杂的测试中,DataFixture 同样可以使用真实的数据库或是 Rails Fixture 来完成这样的对应,但是总体的目的就是使测试和测试数据有效性的耦合分离:

复制代码
def get_user identifier
case identifier
when :registered then return User.find '....'
end
end

4.Navigator

与界面元素类似,URL 也是一类易变且难以表达意图的元素,因此我们可以使用 Navigator 使之与测试解耦。具体做法和 Test Data 相似,这里就不赘述了,下面是一个例子:

复制代码
navigate_to detail_page_for @product
on ProductDetailPage do
....
end

5. Shortcut

前面我们已经有了一个很好的基础,将 Selenium 测试与各种脆弱且意图不明的元素分离开了,那么最后 shortcut 不过是在蛋糕上面最漂亮的奶油罢了——定义具有漂亮语法的 helper:

复制代码
def should_login_successfully user
on LoginPage do
assert_equal 'Welcome!', welcome_message
login_as user
end
assert on WelcomeRegisteredUserPage
end

然后是另外一个 magic 方法:

复制代码
def given identifer
words = identifier.to_s.split '_'
eval "get_#{words.last} :#{words[0..-2].join '_'}"
end

之前的测试就可以被改写为:

复制代码
def test_should_xxxx
should_login_successfully given :registered_user
end

这是一种结论性的 shortcut 描述,我们还可以有更 behaviour 的写法:

复制代码
def login_on page_type
on page_type do
assert_equal 'Welcome!', welcome_message
login_as @user
end
end
def login_successfully
on WelcomeRegisteredUserPage
end
def given identifer
words = identifier.to_s.split '_'
eval "@#{words.last} = get_#{words.last} :#{words[0..-2].join '_'}"
end

最后,测试就会变成类似验收条件的样子:

复制代码
def test_should_xxx
given :registered_user
login_on LoginPage
assert login_successfully
end

总之 shortcut 是一个无关好坏,只关乎想象力的东西,尽情挥洒 Ruby DSL 吧!

结论

Selenium 是一个让人又爱又恨的东西,错误地使用 Selenium 会给整个敏捷团队的开发节奏带来灾难性的影响。不过值得庆幸的是正确地使用 Selenium 的原则也是相当的简单:

  1. 通过将脆弱易变的页面元素和测试分离开,使得页面的变化不会对测试产生太大的影响。
  2. 明确指定测试数据的意图,不在测试用使用任何具体的数据。
  3. 尽一切可能,明确地表达出测试的意图,使测试易于理解。

当然,除了遵循这几个基本原则之外,使用 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,使得所有人的工作都变得简单起来。


作者简介:徐昊,ThoughtWorks 咨询师和敏捷过程教练; BJUG (Beijing Java User Group)和 AgileChina 主要创始人之一;RSSer(Ruby,Smalltalk & Scheme)。目前主要致力于研究编译理论和推广 DSL(Domain Specified Language)在实际项目中的应用。他的博客地址是: http://www.blogjava.net/raimundox

2007-05-21 22:467249

评论

发布
暂无评论
发现更多内容

架构师训练营-第五章-课程总结

而立

极客大学架构师训练营

第五周总结

叶鹏

架构师训练营 - 作业 - 第五讲

吕浩

架构师训练营-第五章-一致性hash算法

而立

极客大学架构师训练营

架构师训练营第五周 - 总结

Larry

第五周作业

CP

架构第四周课程作业

dj_cd

极客大学架构师训练营

架构师训练营第 5 周作业

Season

一致性哈希 一致性Hash算法

架构师训练营第五周学习总结

Bruce Xiong

免费的GPU,还有全系列的OpenJDK

孙苏勇

Java 学习 gpu Openjdk Colab

Week05 学习心得 - 技术选型

极客大学架构师训练营

week5 总结

雪涛公子

架构师训练营 第五周 作业

极客

第五周总结

Linuxer

极客大学架构师训练营

架构师训练营第五周作业

Geek_2dfa9a

第五周总结

nihuihua

week5. 总结心得

dj_cd

极客大学架构师训练营

架构师 0 期 | 缓存--提升性能大杀器

刁架构

极客大学架构师训练营

第五周作业

路人

极客大学架构师训练营

第五周作业:一致性 hash 算法

Larry

「架构师训练营」第 5 周总结

guoguo 👻

极客大学架构师训练营

架构师训练营第五周作业

fenix

第五周作业

nihuihua

第五周学习总结

CP

架构师训练营第 5 周学习总结

Season

缓存 反向代理 消息队列

第五周 架构师技术选型总结

netbanner

面向对象学习

一叶知秋

week5 作业

雪涛公子

一致性hash的Java代码实现

叶鹏

第四周架构课程总结心得

dj_cd

极客大学架构师训练营

架构师训练营Week5学习总结

小高

应用Selenium和Ruby进行面向领域的Web测试_研发效能_徐昊_InfoQ精选文章