http://www.infoq.com/cn/articles/domain-web-testing
應用Selenium進行Web測試往往會存在幾個bad smell:
1.大量使用name, id, xpath等頁面元素。無論是功能修改、UI重構(gòu)還是交互性改進都會影響到這些元素,這使得Selenium測試變得非常脆弱。
2.過于細節(jié)的頁面操作不容易體現(xiàn)出行為的意圖,一段時間之后就很難真正把握測試原有的目的了,這使得Selenium測試變得難于維護。
3.對具體數(shù)據(jù)取值的存在依賴,當個別數(shù)據(jù)不再合法的時候,測試就會失敗,但這樣的失敗并不能標識功能的缺失,這使得Selenium測試變得脆弱且難以維護。
而這幾點直接衍生的結(jié)果就是不斷地添加新的測試,而極少地去重構(gòu)、利用原有測試。其實這到也是正常,單元測試測試寫多了,也有會有這樣的問題。不過比較要命的是,Selenium的執(zhí)行速度比較慢(相對單元測試),隨著測試逐漸的增多,運行時間會逐漸增加到不可忍受的程度。一組意圖不明難以維護的Selenium測試,可以很輕松地在每次build的時候殺掉40分鐘甚至2個小時的時間,在下就有花2個小時坐在電腦前面等待450個Selenium測試運行通過的悲慘經(jīng)歷。因此合理有效地規(guī)劃Selenium測試就顯得格外的迫切和重要了。而目前比較行之有效的辦法,往大了說,可以叫domain based web testing,具體來講,就是Page Object Pattern。
Page Object Pattern里有四個基本概念:Driver, Page, Navigator和Shortcut。Driver是測試真正的實現(xiàn)機制,比如Selenium,比如Watir,比如HttpUnit。它們懂得如何去真正執(zhí)行一個web行為,通常包含像click,select,type這樣的表示具體行為的方法;Page是對一個具體頁面的封裝,它們了解頁面的結(jié)構(gòu),知道諸如id, name, class,xpath這類實現(xiàn)細節(jié),并描述用戶可以在其上進行何種操作;Navigator則代表了URL,表示一些不經(jīng)頁面操作的直接跳轉(zhuǎn);最后Shortcut就是helper方法了,需要看具體的需要了。下面來看一個超級簡單的例子——測試登錄頁面。
1. Page Object
假設我們使用一個單獨的Login Page進行登錄,那么我們可能會將登錄的操作封裝在一個名為LoginPage的page object里:
1 class LoginPage
2 def initialize driver
3 @driver = driver
4 end
5
6 def login_as user
7 @driver.type 'id=
', user[:name]
8 @driver.type 'xpath=
', user[:password]
9 @driver.click 'name=
'
10 @driver.wait_for_page_to_load
11 end
12 end
login_as是一個具有業(yè)務含義的頁面行為。在login_as方法中,page object負責通過依靠id,xpath,name等信息完成登錄操作。在測試中,我們可以這樣來使用這個page object:
1 page = LoginPage.new $selenium
2 page.login_as :name => 'xxx', :password => 'xxx'
3
不過既然用了ruby,總要用一些ruby sugar吧,我們定義一個on方法來表達頁面操作的環(huán)境:
1 def on page_type, &block
2 page = page_type.new $selenium
3 page.instance_eval &block if block_given?
4 end
之后我們就可以使用page object的類名常量和block描述在某個特定頁面上操作了:
1 on LoginPage do
2 login_as :name => 'xxx', :password => 'xxx'
3 end
4
除了行為方法之外,我們還需要在page object上定義一些獲取頁面信息的方法,比如獲取登錄頁面的歡迎詞的方法:
def welcome_message
@driver.get_text 'xpath=
'
end
這樣測試也可表達得更生動一些:
1 on LoginPage do
2 assert_equal 'Welcome!', welcome_message
3 login_as :name => 'xxx', :password => 'xxx'
4 end
當你把所有的頁面都用Page Object封裝了之后,就有效地分離了測試和頁面結(jié)構(gòu)的耦合。在測試中,只需使用諸如login_as, add_product_to_cart這樣的業(yè)務行為,而不必依靠像id,name這些具體且易變的頁面元素了。當這些頁面元素發(fā)生變化時,只需修改相應的page object就可以了,而原有測試基本不需要太大或太多的改動。
2. Assertation
只有行為還夠不成測試,我們還要判斷行為結(jié)果,并進行一些斷言。簡單回顧一下上面的例子,會發(fā)現(xiàn)還有一些很重要的問題沒有解決:我怎么判斷登錄成功了呢?我如何才能知道真的是處在登錄頁面了呢?如果我調(diào)用下面的代碼會怎樣呢?
1 $selenium.open url_of_any_page_but_not_login
2 on LoginPage {
}
因此我們還需要向page object增加一些斷言性方法。至少,每個頁面都應該有一個方法用于判斷是否真正地達到了這個頁面,如果不處在這個頁面中的話,就不能進行任何的業(yè)務行為。下面修改LoginPage使之包含這樣一個方法:
1 LoginPage.class_eval do
2 include Test::Unit::Asseration
3 def visible?
4 @driver.is_text_present(
) && @driver.get_location == 
5 end
6 end
在visible?方法中,我們通過對一些特定的頁面元素(比如URL地址,特定的UI結(jié)構(gòu)或元素)進行判斷,從而可以得之是否真正地處在某個頁面上。而我們目前表達測試的基本結(jié)構(gòu)是由on方法來完成,我們也就順理成章地在on方法中增加一個斷言,來判斷是否真的處在某個頁面上,如果不處在這個頁面則不進行任何的業(yè)務操作:
1 def on page_type, &block
2 page = page_type.new $selenium
3 assert page.visible?, "not on #{page_type}"
4 page.instance_eval &block if block_given?
5 page
6 end
7
這個方法神秘地返回了page對象,這里是一個比較tricky的技巧。實際上,我們只想利用page != nil這個事實來斷言頁面的流轉(zhuǎn),比如,下面的代碼描述登錄成功的頁面流轉(zhuǎn)過程:
on LoginPage do
assert_equal 'Welcome!', welcome_message
login_as :name => 'xxx', :password => 'xxx'
end
assert on WelcomeRegisteredUserPage
除了這個基本斷言之外,我們還可以定義一些業(yè)務相關(guān)的斷言,比如在購物車頁面里,我們可以定義一個判斷購物車是否為空的斷言:
1 def cart_empty?
2 @driver.get_text('xpath=
') == 'Shopping Cart(0)'
3 end
需要注意的是,雖然我們在page object里引入了Test::Unit::Asseration模塊,但是并沒有在斷言方法里使用任何assert*方法。這是因為,概念上來講page object并不是測試。使之包含一些真正的斷言,一則概念混亂,二則容易使page object變成針對某些場景的test helper,不利于以后測試的維護,因此我們往往傾向于將斷言方法實現(xiàn)為一個普通的返回值為boolean的方法。
3. Test Data
測試意圖的體現(xiàn)不僅僅是在行為的描述上,同樣還有測試數(shù)據(jù),比如如下兩段代碼:
1 on LoginPage do
2 login_as :name => 'userA', :password => 'password'
3 end
4 assert on WelcomeRegisteredUserPage
5
6 registered_user = {:name => 'userA', :password => 'password'}
7 on LoginPage do
8 login_as registered_user
9 end
10 assert on WelcomeRegisteredUserPage
測試的是同一個東西,但是顯然第二個測試更好的體現(xiàn)了測試意圖:使用一個已注冊的用戶登錄,應該進入歡迎頁面。我們看這個測試的時候,往往不會關(guān)心用戶名啊密碼啊具體是什么,我們關(guān)心它們表達了怎樣的測試案例。我們可以通過DataFixture來實現(xiàn)這一點:
1 module DataFixture
2 USER_A = {:name => 'userA', :password => 'password'}
3 USER_B = {:name => 'userB', :password => 'password'}
4
5 def get_user identifier
6 case identifier
7 when :registered then return USER_A
8 when :not_registered then return USER_B
9 end
10 end
11 end
在這里,我們將測試案例和具體數(shù)據(jù)做了一個對應:userA是注冊過的用戶,而userB是沒注冊的用戶。當有一天,我們需要將登錄用戶名改為郵箱的時候,只需要修改DataFixture模塊就可以了,而不必修改相應的測試:
1 include DataFixtureDat
2
3 user = get_user :registered
4 on LoginPage do
5 login_as user
6 end
7 assert on WelcomeRegisteredUserPage
當然,在更復雜的測試中,DataFixture同樣可以使用真實的數(shù)據(jù)庫或是Rails Fixture來完成這樣的對應,但是總體的目的就是使測試和測試數(shù)據(jù)有效性的耦合分離:
1 def get_user identifier
2 case identifier
3 when :registered then return User.find '
.'
4 end
5 end
4.Navigator
與界面元素類似,URL也是一類易變且難以表達意圖的元素,因此我們可以使用Navigator使之與測試解耦。具體做法和Test Data相似,這里就不贅述了,下面是一個例子:
1 navigate_to detail_page_for @product
2 on ProductDetailPage do
3
.
4 end
5. Shortcut
前面我們已經(jīng)有了一個很好的基礎(chǔ),將Selenium測試與各種脆弱且意圖不明的元素分離開了,那么最后shortcut不過是在蛋糕上面最漂亮的奶油罷了——定義具有漂亮語法的helper:
1 def should_login_successfully user
2 on LoginPage do
3 assert_equal 'Welcome!', welcome_message
4 login_as user
5 end
6 assert on WelcomeRegisteredUserPage
7 end
然后是另外一個magic方法:
1 def given identifer
2 words = identifier.to_s.split '_'
3 eval "get_#{words.last} :#{words[0..-2].join '_'}"
4 end
之前的測試就可以被改寫為:
def test_should_xxxx
should_login_successfully given :registered_user
end
這是一種結(jié)論性的shortcut描述,我們還可以有更behaviour的寫法:
1 def login_on page_type
2 on page_type do
3 assert_equal 'Welcome!', welcome_message
4 login_as @user
5 end
6 end
7
8 def login_successfully
9 on WelcomeRegisteredUserPage
10 end
11
12 def given identifer
13 words = identifier.to_s.split '_'
14 eval "@#{words.last} = get_#{words.last} :#{words[0..-2].join '_'}"
15 end
最后,測試就會變成類似驗收條件的樣子:
1 def test_should_xxx
2 given :registered_user
3 login_on LoginPage
4 assert login_successfully
5 end
總之shortcut是一個無關(guān)好壞,只關(guān)乎想象力的東西,盡情揮灑Ruby DSL吧:D
結(jié)論
Selenium是一個讓人又愛又恨的東西,錯誤地使用Selenium會給整個敏捷團隊的開發(fā)節(jié)奏帶來災難性的影響。不過值得慶幸的是正確地使用Selenium的原則也是相當?shù)暮唵危?br>
1.通過將脆弱易變的頁面元素和測試分離開,使得頁面的變化不會對測試產(chǎn)生太大的影響。
2.明確指定測試數(shù)據(jù)的意圖,不在測試用使用任何具體的數(shù)據(jù)。
3.盡一切可能,明確地表達出測試的意圖,使測試易于理解。
當然,除了遵循這幾個基本原則之外,使用page object或其他domain based web testing技術(shù)是個不錯的選擇。它們將會幫助你更容易地控制Selenium測試的規(guī)模,更好地平衡覆蓋率和執(zhí)行效率,從而更加有效地交付高質(zhì)量的Web項目。
鳴謝
此文中涉及的都是我最近三周以來對Selenium測試進行重構(gòu)時所采用的真實技術(shù)。感謝Nick Drew幫助我清晰地劃分了Driver, Page, Nagivator和Shortcut的層次關(guān)系,它們構(gòu)成我整個實踐的基石;感謝Chris Leishman,在和他pairing programming的過程中,他幫助我錘煉了Ruby DSL;還有Mark Ryall和Abhi,是他們第一次在項目中引入了Test Data Fixture,使得所有人的工作都變得簡單起來。