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