本文是一個速成班,介紹了如何編寫可維護的JavaScript。我們向一個貫穿全文的例子中逐漸添加新功能,並遵循如下簡單的規則:編寫一個單元測試,然後讓它通過。每個測試都起到品質反饋迴路的作用,給那些想修改產品代碼的人建立了一個安全保護網,以及一份可以執行的文檔。通過簡單、失敗的測試開始每個功能,我們可以保證所有的功能都被測試覆蓋到了。我們也避免了重寫代碼後再進行測試的高昂代價。考慮到JavaScript開發人員很容易深陷泥沼、難以自拔的事實,這顯得尤其難能可貴──只需要考慮一下DOM API和JavaScript語言本身之間有多少全域可變狀態就夠了。
這個貫穿全文的例子是賭場的3軸老虎 機。每軸有5種可能的狀態,用圖片來表示。當老虎 機的play按鈕被按下時,每個軸會隨機給出一種狀態。老虎 機的餘額根據三個軸的狀態是否相等而增加或者減少。
我們的工具有stubs、mock對象和一丁點的依賴注入。我們使用JsUnit運行單元測試,以及一個叫做JsMock的JavaScript mock物件程式庫。整合測試──單元測試的補充,則超出了本文的範圍。這並不意味著整合測試不重要──僅僅是因為我們希望得到更快的反饋,而不是從類似 Selenium和Watir這樣的工具那裡得到更慢、更全面的反饋。
JsUnit,一個JavaScript單元測試架構
JsUnit是JavaScript的開源單元測試架構。它受到JUnit的啟發,並完全用JavaScript編寫。作為最流行的 JavaScript單元測試架構,它還提供了一些ant任務,使開發人員在持續整合伺服器上構建時很容易運行測試套件。持續整合是另外一個重要的實踐, 其與TDD結合使用時是對品質的一個“強有力的保證”,不過這也超出了本文的範圍。
讓我們從JsUnit的test runner開始吧。Test runner是一個普通的HTML和JavaScript web頁面,意味著你的單元測試可以直接在瀏覽器或者你想支援的瀏覽器中運行。解壓縮JsUnit下載檔案,你就會在根目錄下發現testRunner.html。你不需要通過web伺服器訪問它──只需要通過檔案系統載入它進行瀏覽就可以了。
Test runner最重要的控制項是位於頁面頂部的檔案輸入欄。這個控制項意在擷取一個指向測試頁面或者測試頁面套件的路徑。現在我們看一個JsUnit測試頁面的簡單例子。
<html> <title>A unit test for drw.SystemUnderTest class</title> <head> <script type='text/javascript' src='../jsunit/app/jsUnitCore.js'></script> <script type='text/javascript' src='../app/system_under_test.js'></script> <script type='text/javascript'> function setUp(){ // perform fixture set up } function tearDown() { // clean up } function testOneThing(){ // instantiating a SystemUnderTest, a class in the drw namespace var sut = new drw.SystemUnderTest(); var thing = sut.oneThing(); assertEquals(1, thing); } function testAnotherThing(){ var sut = new drw.SystemUnderTest(); var thing = sut.anotherThing(); assertNotEquals(1, thing); } </script> </head> <body/></html>
JsUnit與其它xUnit架構有很多相似之處。正如你期望的那樣,test runner載入測試頁面,調用每個測試函數。每個測試函數的調用被夾在setUp和tearDown調用之間。setUp函數給測試者提供了一個機會, 可以選擇在此構造測試夾具(test fixture)。測試夾具用以給頁面中所有的測試準備狀態。tearDown函數則給測試者提供了另外一個機會,可以去清除或者重設測試夾具。
然而,與其他的xUnit架構相比,JsUnit在測試生命週期方面稍有不同。每個測試頁面被載入到獨立的視窗中,以防止應用程式代碼通過開放類覆蓋測試架構代碼。在每個被載入的視窗中,所有的單元測試函數都會被調用到。頁面不會為每個測試函數重新載入。從另一方面來說,在JUnit中,測試頁面等同於一個測試案例,test runner會給每個測試方法產生一個單獨的測試案例的執行個體。換言之,
JsUnit載入有N個測試函數的測試頁面,只需要1次
JUnit建立有N個測試方法的測試案例,需要N次
JavaScript開發人員因此更容易陷入“一招不慎,滿盤皆輸”的境地,因為對測試頁面狀態的改變會影響後續測試的結果。而Java開發人員在改變 測試案例對象的狀態時則不會遇到這種危險。JsUnit為什麼這樣做呢,而不是對每個測試,簡單地重新載入一次測試頁面?這是因為在測試套件中給每個測試 函數重新建立DOM會有效能消耗。值得慶幸的是,JavaScript開發人員不必過多關心全域狀態變化帶來的負面影響。在諸如JVM和CLR的程式平台 上,修改靜態變數會影響整個測試套件中所有後續的測試,而不僅僅是同一個測試案例的測試。
jsUnitCore.js指令碼必須嵌入到所有的 測試頁面中。這個重要的檔案位於JsUnit下載檔案解壓之後的app目錄中。它包含一組斷言函數,與其他xUnit架構的行為多少有些相同。一個細微的 區別源於JavaScript有兩個等於符號。一個是相等(==)操作符,還有一個三等(===)操作符。比如,下面的第一個運算式是true,第二個是 false:
0 == false
0 === false
為什麼會這樣呢?相等操作符不像三等操作符那樣嚴格,允許運行時對第一個布林運算式執行類型轉換。所以不難理解新手會認為下面的斷言會通過:
assertEquals(false, 0);
實際上這個斷言會失敗,因為JsUnit架構提供的斷言函數對所有的比較採用更嚴格的三等操作符,而不是相等操作符。通過避免相等操作符,JsUnit能夠避免許多看似正確實則錯誤的測試。
Stubs vs. Mocks
讓我們通過老虎 機這一例子,看一看stubs和mock對象。由於這個單元測試關注單個對象,我們建立一個老虎 機,並把它當作被測系統。現在讓我們寫一個簡單的測試,產生老虎 機。
function testRender() { var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; var randomNumbers = [2, 1, 3]; var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); slotMachine.render(); assertEquals('Pay to play', buttonStub.value); assertTrue(buttonStub.disabled); assertEquals(0, balanceStub.innerHTML); assertEquals('images/2.jpg', reelsStub[0].src); assertEquals('images/1.jpg', reelsStub[1].src); assertEquals('images/3.jpg', reelsStub[2].src);}
testRender函數使用了兩個DOM元素的stub,把它們都注入到被測系統的建構函式中,並調用render方法。測試的最後對render方法的期望結果進行斷言。請注意通過使用DOM元素的stub,我們可以測試render方法的結果,而不必實際去做任何事情,這些事情可能導致測試頁面的其他測試失效。這種方法與使用真實DOM元素各有利弊。使用真實的DOM元素更容易發現跨瀏覽器不相容的bug,但是如果每個測試最後或者tearDown時沒有進行重設,你的測試本身也更容易帶來bug。
被測系統並未直接調用全域函數Math.random,來決定每個軸初始的圖片狀態。相反,老虎 機是依賴建立時提供給它的參數,來得到這些數字。這讓我們可以測試一段不確定的代碼,好像完全可以預測一樣。請注意測試沒有覆蓋瀏覽器原生的Math.random實現,從而避免了狀態變化的風險和副作用。
等等,等一會兒... 測試函數有不止一個斷言,這樣行嗎?敏捷社區中部分人認為每個測試中有多於一個斷言是邪惡的。然而,給用來賺錢的實際的應用程式很少會這麼寫測試套件。當親眼看到JUnit架構本身實物測試套件中每個測試有多少斷言時,相信會有很多人非常驚訝。
對象的建構函式和render方法看上去是這樣的:
/** * Constructor for the slot machine. */drw.SlotMachine = function(buttonElement, balanceElement, reels, random, networkClient) { this.buttonElement = buttonElement; this.balanceElement = balanceElement; this.reels = reels; this.random = random; this.networkClient = networkClient; this.balance = 0;};drw.SlotMachine.prototype.render = function() { this.buttonElement.disabled = true; this.buttonElement.value = 'Pay to play'; this.balanceElement.innerHTML = 0; for(var i = 0; i < this.reels.length;){ this.reels[i++].src = 'images/' + this.random() + '.jpg'; }};
讓我們往老虎 機裡放一些錢。在這個情境下,老虎 機非同步呼叫伺服器端以返回使用者餘額。這很有挑戰性,因為單元測試中沒有網路,AJAX調用會失敗。當我們編寫單元測試時,我們應該盡量編寫沒有副作用的代碼,IO當然也屬於這一類。
function testGetBalanceGoesToNetwork(){ var url, callback; var networkStub = { send : function() { url = arguments[0]; callback = arguments[1]; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); assertEquals('/getBalance.jsp', url); assertEquals('function', typeof callback);}
這個測試使用了網路stub。什麼是stub呢?stub與mock有什麼區別呢?許多開發人員經常混淆這個兩個詞,認為它們是同義字。測試社區中,stub這個詞是保留給基於狀態測試的。JavaScript中它通常是指一個簡單的object literal,能夠返回預先硬式編碼數值。而mock這個詞則是保留給互動測試的。Mock可以針對行為訓練。這些行為與被測對象進行互動,並且可以驗證這些互動。
通過網路用戶端stub,我們現在能夠測試getBalance方法。通過本地變數url和callback,應用於建構函式的object literal stub能夠記錄它與被測系統的互動行為。這些本地變數使我們能夠在測試的最後執行斷言。不幸的是,我們用錯工具了。這是一個經典例子,說明了stub的局限性,以及為什麼使用mock對象。這個測試的目的不是在給了一定狀態之後,驗證被測系統的行為。測試關注的是drw.SlotMachine執行個體與它的一個共同作業者——網路用戶端之間的互動。
JsMock,JavaScript的Mock物件程式庫
仔細查看你會發現testGetBalanceGoesToNetwork建立了自己的微型mocking架構。現在讓我們重構測試,使用一種通用的mocking架構。我們需要在測試頁面添加一個獨立的指令碼標籤,並像這樣重寫測試:
<script type='text/javascript' src='../jsmock/jsmock.js'></script> function testGetBalanceWithMocks(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); mockControl.verify(); }
現在使用更少的代碼就能得到相同的反饋,我們甚至打下了更簡單的基礎,有利於進一步測試。如何做到這樣的呢?代碼的第一行使用JsMock提供的MockControl建構函式建立一個對象。代碼於是建立了一個有send方法的mock對象。在一個有實際NetworkClient類的應用程式中,我們甚至不必把一個object literal應用到createMock方法中。JsMock可以通過原型推斷出來。
var mock = mockControl.createMock(NetworkClient.prototype);
一旦network用戶端的mock對象建立之後,我們編程期望帶有特定參數的send方法被調用了一次。我們關心伺服器資源名稱是正確的,並且第二個參數是一個回呼函數。mock對象被注入到被測系統的建構函式中,通過MockControl對象的驗證方法,驗證互動行為從而得出結論。如果因為任何原因,老虎 機的實現沒有調用network用戶端的send方法,或者與預期的參數不符,驗證方法會拋出一個異常,測試會失敗。
現在讓我們編寫另外一個測試,驗證一個drw.SlogMachine執行個體什麼時候、多久回到用戶端一次。如果伺服器端響應完成之前getBalance方法被調用,我們不希望餘額被返回兩次。這會導致老虎 機的餘額兩次返回到使用者賬戶,並且花費額外的頻寬。
function testGetBalanceWithMocksToTheNetworkOnce(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response mockControl.verify(); }
還記得我們在這裡的第一個crack嗎?當時我們建立了一個自己的微型mocking架構?那看上去像是一個實用的解決方案,但是你想像一下測試這樣的互動行為,會寫多少代碼。僅僅由於參數的原因,讓我們看看一個純粹的stub解決方案,有多少瑕疵。
function testGetBalanceFlawed(){ var networkStub = { send : function() { if(this.called) throw new Error('This should not be called > 1 time'); this.called = true; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response }
測試斷言,網路用戶端只被調用了一次,第一次使用之後網路stub就簡單地拋出錯誤。這裡有一個小問題,因為測試是人工控制待測對象斷言。比如,如果待測系統將要多次調用網路stub的send函數,而它自己處理拋出的異常,那麼測試永遠也不會失敗,因為test runner永遠也不會收到任何出問題的通知。一個解決方案是建立更精緻的微型mocking架構,但是通用的諸如JsMock這樣的方法通常更簡單。
JsMock不僅僅能夠讓我們測試方法調用順序和參數值。這個測試示範老虎 機在網路發生故障時的行為。
function testGetBalanceWithFailure(){ var buttonStub = {}; var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andThrow('network failure'); var slotMachine = new drw.SlotMachine(buttonStub, null, null, null, networkMock); slotMachine.getBalance(); assertEquals('Sorry, can't talk to the server right now', buttonStub.value); mockControl.verify(); }
這裡我們驗證老虎 機在網路發生故障時可以優雅地失敗。這是單元測試能夠勝過系統整合測試的一個很好的例子。您能想像每個QA/發布周期中,對伺服器每個整合點手工類比一個網路故障花費的時間和金錢嗎?
getBalance方法的實現現在看上去是這樣的:
drw.SlotMachine.prototype.getBalance = function() { if(this.balanceRequested) return; try{ // this line of code requires the very excellent functional.js // library, found at http://osteele.com/sources/javascript/functional this.networkClient.send('/getBalance.jsp', this.deposit.bind(this)); this.balanceRequested = true; }catch(e){ this.buttonElement.value = 'Sorry, can't talk to the server right now'; }};
與stub相比,mock的一個缺點是和被測系統的耦合相當多,至少希望拿來就用。當被測系統的行為與期望不符時,你希望測試失敗──你並不希望被封裝的實現細節有任何變化時,測試就會失敗。為了彌補這種情況,JsMock提供了放寬期望的能力。其實你已經看到了這個例子。當我們準備網路mock對象時,這樣寫的:
networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function));
我們並沒有指定哪個回呼函數會被用作第二個參數,只需要是一個回呼函數就可以了。如果我們想把這些期望放的更寬些,我們可以這樣嘗試:
networkMock.expects().send(TypeOf.isA(String), TypeOf.isA(Function));
如果我們想引用網路用戶端mock的send方法的實際回呼函數,我們可以使用JsMock架構的andStub方法:
var depositCallback;networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andStub( function(){depositCallback = arguments[1];} );depositCallback({responseText:"10"});
在我們繼續之前,關於mock對象有兩點需要知道。注意到每個測試最後如何調用MockControl的verify方法。這很重要。單元測試不調用verify方法就不會失敗。許多開發人員遇到過這樣的事情,寫了一些標準的單元測試函數之後,就認為把對verify方法從測試函數移到tearDown函數更好。雖然這會節省幾行代碼,也讓你不必在每個測試函數的最後都去記住這一重要細節,不幸的是,它會給你帶來一個新問題:tearDown中拋出的異常會被測試中拋出的第一個異常掩蓋。第二個陷阱是新手經常過度使用mock對象,並用它們完全替代stub。不要這樣。用stub來做基於狀態的測試,使用mock做基於互動的測試。
一個贏錢的情境測試
我們可以用學到的任何知識測試下面的情境。這個測試類比了一個使用者在老虎 機上先輸後贏的情形。
function testLoseThenWin(){ var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; // a losing combination, followed by a winning combination var randomNumbers = [2, 1, 3].concat([4, 4, 4]); var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); var balance = 10; slotMachine.deposit({responseText: String(balance)}); slotMachine.play(); assertEquals(balance - 1, balanceStub.innerHTML); assertEquals('Sorry, try again', buttonStub.value); slotMachine.play(); assertEquals('balance - 2 + 40', 48, balanceStub.innerHTML); assertEquals('You Won!', buttonStub.value); assertEquals('images/4.jpg', reelsStub[0].src); assertEquals('images/4.jpg', reelsStub[1].src); assertEquals('images/4.jpg', reelsStub[2].src);}
drw.SlotMachine類的play方法實現是這樣的:
drw.SlotMachine.prototype.play = function(){ var outcomes = []; var msg = 'Sorry, try again'; for(var i = 0; i < this.reels.length; i++){ this.reels[i].src = 'images/' + (outcomes[i] = this.random()) + '.jpg'; } if(outcomes[0] == outcomes[1] && outcomes[0] == outcomes[2]){ msg = 'You Won!'; this.balance += (outcomes[0] * 10); } this.buttonElement.value = msg; this.balanceElement.innerHTML = --this.balance;};
最後,這是一個可以啟動並執行老虎 機的樣本:
<html> <title>A Slot Machine Demonstration</title> <head> <script type='text/javascript' src='functional.js'></script> <script type='text/javascript' src='slot_machine.js'></script> <script type='text/javascript' src='network_client.js'></script> <script type='text/javascript'> window.onload = function(){ var leftReel = document.getElementById('leftReel'); var middleReel = document.getElementById('middleReel'); var rightReel = document.getElementById('rightReel'); var random = function(){ return Math.floor(Math.random()*5) + 1; // generate 1 through 5 }; slotMachine = new drw.SlotMachine(document.getElementById('buttonElement'), document.getElementById('balanceElement'), [leftReel, middleReel, rightReel], random, new NetworkClient()); slotMachine.render(); slotMachine.getBalance(); }; </script> </head> <body id='body'> <div style="text-align:center; background-color:#BFE4FF; padding: 5px; width: 160px;"> <div>Slot Machine Widget</div> <div style="padding: 5 0 5 0;"> <img id='leftReel'/> <img id='middleReel'/> <img id='rightReel'/> </div> <div>Balance: <span id="balanceElement"></span></div> <input id="buttonElement" style="width:150px" type="button" onclick="slotMachine.play()"></input> </div> </body></html>
參考資料
- JSMock,一個用於JavaScript的具有完備功能的Mock物件程式庫,作者是Justin DeWind
- JsUnit,一個用於用戶端(瀏覽器內)JavaScript的單元測試架構
- Mocks Aren’t Stubs,Martin Fowler的一篇文章
- Functional是一個用於JavaScript的功能程式類庫,作者是Oliver Steele
- Dependency Injection,Martin Fowler的一篇文章
作者簡介
Dennis Byrne住在芝加哥,在DRW Trading工作,這是一個證卷交易公司(proprietary trading firm)和做市商(market maker)。他是一個作家和演講家,是開源社區的活躍分子。
查看英文原文:JavaScript Test Driven Development with JsUnit and JSMock。
原文地址:http://www.infoq.com/cn/articles/javascript-tdd