無論我們使用和Node配合在一起的測試框架,例如Mocha或者Jasmine,還是在像PhantomJS這樣的無頭瀏覽器中執行依賴於DOM的測試,和以前相比,我們有更好的方式來對JavaScript進行單元測試。
然而,這並不意味著我們要測試的程式碼就像我們的工具那樣容易!組織和編寫易於測試的程式碼需要花費一些精力和並對其進行規劃,但是在函數語言程式設計的啟發下,我們發現了一些模式,當我們需要測試我們的程式碼時,這些模式可以幫助我們避免那些“坑”。在這篇文章中,我們會檢視一些有用的小貼士和模式,來幫助我們在JavaScript中編寫可測試的程式碼。
保持業務邏輯和顯示邏輯分離
對於基於JavaScript的瀏覽器應用程式來說,其中一項主要工作就是監聽終端使用者觸發的DOM事件,然後通過執行一些業務邏輯並在頁面上顯示結果,以此對使用者做出反饋。在建立DOM事件監聽器的地方,有時會誘惑你編寫一個匿名函式來完成所有這些工作。這樣帶來的問題是為了測試匿名函式,你不得不去模擬DOM事件。這樣不僅會增加程式碼行數,而且會增加測試執行的時間。
這不僅僅可以應用到DOM上。在瀏覽器和Node上的很多API,都被設計成觸發和監聽事件,或者等待其它型別的非同步工作完成。憑經驗說,如果你編寫了大量的匿名回撥函式,那麼你的程式碼可能不會容易被測試。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// hard to test $('button').on('click', () => { $.getJSON('/path/to/data') .then(data => { $('#my-list').html('results: ' + data.join(', ')); }); }); // testable; we can directly run fetchThings to see if it // makes an AJAX request without having to trigger DOM // events, and we can run showThings directly to see that it // displays data in the DOM without doing an AJAX request $('button').on('click', () => fetchThings(showThings)); function fetchThings(callback) { $.getJSON('/path/to/data').then(callback); } function showThings(data) { $('#my-list').html('results: ' + data.join(', ')); } |
對非同步程式碼使用回撥或者Promise
在上述的示例程式碼中,我們經過重構的函式fetchThings會執行一個AJAX請求,以非同步的方式完成了大部分工作。這意味著我們不能執行函式並測試它是否按照我們預期的那樣執行,因為我們不知道它什麼時候執行完。
解決這個問題最常見的方法,是向函式中傳遞一個回撥函式作為引數,作為非同步呼叫。這樣,在你的單元測試中,你可以在傳遞的回撥函式中執行一些斷言。
另外一種常見並且越來越流行的組織非同步程式碼方法,是使用Promise API的方式。幸運的是,$.ajax和其它大部分jQuery的非同步函式已經返回了Promise物件,因此它已經涵蓋了大部分常見的用例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 很難測試;我們不知道AJAX請求會執行多長時間 function fetchData() { $.ajax({ url: '/path/to/data' }); } //可測試的;我們傳入一個回撥函式,然後在其中執行斷言 function fetchDataWithCallback(callback) { $.ajax({ url: '/path/to/data', success: callback, }); } //同樣可測試的:在返回的Promise解析完後,我們可以執行斷言 function fetchDataWithPromise() { return $.ajax({ url: '/path/to/data' }); } |
避免副作用
要編寫那些使用引數並且返回值僅僅依賴那些引數的函式,就像將數字傳入數學公式,然後取得結果。如果你的函式依賴於一些外部的狀態(例如類例項的屬性或者某些檔案的內容),那麼你在測試這個函式之前,就不得不去設定一些狀態,在測試用例中需要更多的設定。你不得不去認為那些正在執行的程式碼不會修改同一個的狀態。
同樣,你需要避免編寫那些會修改外部狀態的函式,例如向檔案寫入內容或者向資料庫儲存資料。這會避免一些副作用,來影響你測試其他程式碼的能力。一般來說,最好是將副作用和程式碼控制在一起,讓“表面積”儘可能小。對於類和物件例項來說,類方法的副作用應該被限制在被測試的類例項的範圍內。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 很難測試;我們不得不設定一個globalListOfCars物件和一個名為#list-of-models的DOM結構,然後才能測試這段程式碼 function processCarData() { const models = globalListOfCars.map(car => car.model); $('#list-of-models').html(models.join(', ')); } // 容易測試;我們傳遞一個引數然後測試它的返回值,而不需要設定任何全域性變數或者檢查任何DOM結果 function buildModelsString(cars) { const models = cars.map(car => car.model); return models.join(','); } |
使用依賴注入
在函式中,有一種通用的模式,可以用來降低對外部狀態的使用,這就是依賴注入 —— 將函式的所有外部需要都通過函式引數的方式傳遞給函式。
1 2 3 4 5 6 7 8 9 10 11 |
// 依賴於一個外部狀態資料連線例項;很難測試 function updateRow(rowId, data) { myGlobalDatabaseConnector.update(rowId, data); } // 將資料庫連線例項作為引數傳遞給函式;很容易測試。 function updateRow(rowId, data, databaseConnector) { databaseConnector.update(rowId, data); } |
使用依賴注入的一個主要好處,是你可以在單元測試中傳入mock物件,這樣就不會導致真的副作用(在這個例子中,就是更新資料庫行),你只需要斷言你的mock物件是按照期望的方式執行即可。
為每一個函式設定一個唯一的目的
將長函式分解成一系列小的、單一職責的函式。這樣我們可以更容易的去測試每一個函式是否是正確的,而不再希望一個大函式在返回結果之前就正確的做了所有的事情。
在函數語言程式設計中,將幾個單一職責的函式拼在一起的行為稱作“組合”。Underscore.js甚至有一個名為_.compose的函式,它將一個函式列表中的函式串在一起,將每一函式的返回結果作為輸入傳遞給下一個函式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// 很難測試 function createGreeting(name, location, age) { let greeting; if (location === 'Mexico') { greeting = '!Hola'; } else { greeting = 'Hello'; } greeting += ' ' + name.toUpperCase() + '! '; greeting += 'You are ' + age + ' years old.'; return greeting; } // 很容易測試 function getBeginning(location) { if (location === 'Mexico') { return '¡Hola'; } else { return 'Hello'; } } function getMiddle(name) { return ' ' + name.toUpperCase() + '! '; } function getEnd(age) { return 'You are ' + age + ' years old.'; } function createGreeting(name, location, age) { return getBeginning(location) + getMiddle(name) + getEnd(age); } |
不要改變引數
在JavaScript中,陣列和物件傳遞的是引用,而非值,因此它們是可變的。這意味著當你將物件或者陣列作為引數傳遞給函式時,你的程式碼和使用你傳遞的物件或陣列的函式,都有能力去修改記憶體中同一個陣列或者物件。這意味著當你測試你自己的程式碼時,你必須信任所有你呼叫的函式中,沒有任何函式會修改你的物件。每當你新增一些新的可以修改同一個物件的程式碼時,跟蹤物件應該是什麼樣子就會變得越來越困難,從而更難去測試它們。
相反,當你有一個函式需要使用物件或者陣列時,你應該在程式碼中對待物件或者陣列就像它們是隻讀的。你可以根據需要建立新的物件或者陣列,然後對齊填充。或者,使用Undersocre或者Lodash去對傳入的物件或者陣列做一個拷貝,然後再對齊進行操作。更好的選擇是,使用一些像Immutable.js這樣的工具,去建立只讀的資料結構。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 修改了傳入的物件 function upperCaseLocation(customerInfo) { customerInfo.location = customerInfo.location.toUpperCase(); return customerInfo; } // 返回了一個新的物件 function upperCaseLocation(customerInfo) { return { name: customerInfo.name, location: customerInfo.location.toUpperCase(), age: customerInfo.age }; } |
在編碼之前先寫測試
在編碼之前先寫單元測試的過程被稱作測試驅動開發(TDD)。大量的開發者發現TDD非常有用。
通過先編寫測試用例,你就強迫自己從使用你程式碼的開發者角度來考慮你要暴露的API,它還幫助你確保你只會編寫足夠的程式碼來滿足測試用例的要求,而不要對解決方案“過度施工”,從而帶來不必要的複雜性。
在實踐中,TDD作為一條紀律,要覆蓋所有的程式碼改動可能會比較困難。但是當它看上去值得嘗試的時候,這就是一個很好的方式來保證你的所有程式碼都是可測試的。
總結
在編寫和測試複雜的JavaScript應用的時候,我們都知道有一些很容易遇到的“陷阱”,但我希望通過這些貼士和提醒,可以讓我們的程式碼儘量簡單和函式化,我們可以做到讓測試覆蓋率很高,讓整體的程式碼複雜性很低!
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式