編寫可測試的JavaScript代碼
無論我們使用和Node配合在一起的測試框架,例如Mocha或者Jasmine,還是在像PhantomJS這樣的無頭瀏覽器中運行依賴于DOM的測試,和以前相比,我們有更好的方式來對JavaScript進行單元測試。
然而,這并不意味著我們要測試的代碼就像我們的工具那樣容易!組織和編寫易于測試的代碼需要花費一些精力和并對其進行規劃,但是在函數式編程的啟發下,我們發現了一些模式,當我們需要測試我們的代碼時,這些模式可以幫助我們避免那些“坑”。在這篇文章中,我們會查看一些有用的小貼士和模式,來幫助我們在JavaScript中編寫可測試的代碼。
保持業務邏輯和顯示邏輯分離
對于基于JavaScript的瀏覽器應用程序來說,其中一項主要工作就是監聽終端用戶觸發的DOM事件,然后通過運行一些業務邏輯并在頁面上顯示結果,以此對用戶做出反饋。在建立DOM事件監聽器的地方,有時會誘惑你編寫一個匿名函數來完成所有這些工作。這樣帶來的問題是為了測試匿名函數,你不得不去模擬DOM事件。這樣不僅會增加代碼行數,而且會增加測試運行的時間。
與之相反,編寫一個有名字的函數,然后將其傳給事件處理器。通過這種方式,你可以直接針對這個有名字的函數編寫測試用例,而不用去觸發一個假的DOM事件。
這不僅僅可以應用到DOM上。在瀏覽器和Node上的很多API,都被設計成觸發和監聽事件,或者等待其它類型的異步工作完成。憑經驗說,如果你編寫了大量的匿名回調函數,那么你的代碼可能不會容易被測試。
- // 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對象,因此它已經涵蓋了大部分常見的用例。
- // 很難測試;我們不知道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' });
- }
避免副作用
要編寫那些使用參數并且返回值僅僅依賴那些參數的函數,就像將數字傳入數學公式,然后取得結果。如果你的函數依賴于一些外部的狀態(例如類實例的屬性或者某些文件的內容),那么你在測試這個函數之前,就不得不去設置一些狀態,在測試用例中需要更多的設置。你不得不去認為那些正在運行的代碼不會修改同一個的狀態。
同樣,你需要避免編寫那些會修改外部狀態的函數,例如向文件寫入內容或者向數據庫保存數據。這會避免一些副作用,來影響你測試其他代碼的能力。一般來說,***是將副作用和代碼控制在一起,讓“表面積”盡可能小。對于類和對象實例來說,類方法的副作用應該被限制在被測試的類實例的范圍內。
- // 很難測試;我們不得不設置一個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(',');
- }
使用依賴注入
在函數中,有一種通用的模式,可以用來降低對外部狀態的使用,這就是依賴注入 —— 將函數的所有外部需要都通過函數參數的方式傳遞給函數。
- // 依賴于一個外部狀態數據連接實例;很難測試
- function updateRow(rowId, data) {
- myGlobalDatabaseConnector.update(rowId, data);
- }
- // 將數據庫連接實例作為參數傳遞給函數;很容易測試。
- function updateRow(rowId, data, databaseConnector) {
- databaseConnector.update(rowId, data);
- }
使用依賴注入的一個主要好處,是你可以在單元測試中傳入mock對象,這樣就不會導致真的副作用(在這個例子中,就是更新數據庫行),你只需要斷言你的mock對象是按照期望的方式運行即可。
為每一個函數設置一個唯一的目的
將長函數分解成一系列小的、單一職責的函數。這樣我們可以更容易的去測試每一個函數是否是正確的,而不再希望一個大函數在返回結果之前就正確的做了所有的事情。
在函數式編程中,將幾個單一職責的函數拼在一起的行為稱作“組合”。Underscore.js甚至有一個名為_.compose的函數,它將一個函數列表中的函數串在一起,將每一函數的返回結果作為輸入傳遞給下一個函數。
- // 很難測試
- 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這樣的工具,去創建只讀的數據結構。
- // 修改了傳入的對象
- 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應用的時候,我們都知道有一些很容易遇到的“陷阱”,但我希望通過這些貼士和提醒,可以讓我們的代碼盡量簡單和函數化,我們可以做到讓測試覆蓋率很高,讓整體的代碼復雜性很低!































