用 JavaScript 編寫可測試代碼:簡要概述

已發表: 2022-03-11

無論我們是使用 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(', ')); }

在異步代碼中使用回調或承諾

在上面的代碼示例中,我們重構的 fetchThings 函數運行一個 AJAX 請求,它的大部分工作都是異步完成的。 這意味著我們無法運行該函數並測試它是否完成了我們預期的一切,因為我們不知道它何時完成運行。

解決這個問題最常見的方法是將回調函數作為參數傳遞給異步運行的函數。 在您的單元測試中,您可以在您傳遞的回調中運行您的斷言。

說明:在單元測試中使用回調函數作為參數

另一種組織異步代碼的常見且越來越流行的方法是使用 Promise API。 幸運的是,$.ajax 和大多數其他 jQuery 異步函數已經返回了一個 Promise 對象,因此已經涵蓋了很多常見的用例。

 // hard to test; we don't know how long the AJAX request will run function fetchData() { $.ajax({ url: '/path/to/data' }); } // testable; we can pass a callback and run assertions inside it function fetchDataWithCallback(callback) { $.ajax({ url: '/path/to/data', success: callback, }); } // also testable; we can run assertions when the returned Promise resolves function fetchDataWithPromise() { return $.ajax({ url: '/path/to/data' }); }

避免副作用

編寫接受參數並僅基於這些參數返回值的函數,就像將數字衝入數學方程以獲得結果一樣。 如果您的函數依賴於某些外部狀態(例如,類實例的屬性或文件的內容),並且您必須在測試函數之前設置該狀態,則必須在測試中進行更多設置。 您必須相信正在運行的任何其他代碼都不會改變相同的狀態。

插圖:由外部狀態引起的級聯效應。

同樣,避免在運行時編寫改變外部狀態的函數(例如寫入文件或將值保存到數據庫)。 這可以防止可能影響您自信地測試其他代碼的能力的副作用。 一般來說,最好讓副作用盡可能靠近代碼的邊緣,盡可能少的“表面積”。 在類和對象實例的情況下,類方法的副作用應限於被測試的類實例的狀態。

 // hard to test; we have to set up a globalListOfCars object and set up a // DOM with a #list-of-models node to test this code function processCarData() { const models = globalListOfCars.map(car => car.model); $('#list-of-models').html(models.join(', ')); } // easy to test; we can pass an argument and test its return value, without // setting any global values on the window or checking the DOM the result function buildModelsString(cars) { const models = cars.map(car => car.model); return models.join(','); }

使用依賴注入

減少函數使用外部狀態的一種常見模式是依賴注入——將函數的所有外部需求作為函數參數傳遞。

 // depends on an external state database connector instance; hard to test function updateRow(rowId, data) { myGlobalDatabaseConnector.update(rowId, data); } // takes a database connector instance in as an argument; easy to test! function updateRow(rowId, data, databaseConnector) { databaseConnector.update(rowId, data); }

使用依賴注入的主要好處之一是您可以從單元測試中傳入不會導致真正副作用的模擬對象(在這種情況下,更新數據庫行),並且您可以斷言您的模擬對像已被執行以預期的方式。

給每個函數一個單一的目的

將做幾件事的長函數分解成一組短的、單一用途的函數。 這使得測試每個函數是否正確執行其部分變得容易得多,而不是希望一個大函數在返回值之前正確執行所有操作。

在函數式編程中,將幾個單一用途的函數串在一起的行為稱為組合。 Underscore.js 甚至有一個函數_.compose ,它接受一個函數列表並將它們鏈接在一起,獲取每個步驟的返回值並將其傳遞給行中的下一個函數。

 // hard to test 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; } // easy to test 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 中,數組和對像是通過引用而不是值傳遞的,它們是可變的。 這意味著當您將對像或數組作為參數傳遞給函數時,您的代碼和您傳遞對像或數組的函數都能夠更改內存中該數組或對象的相同實例。 這意味著如果您正在測試自己的代碼,您必須相信您的代碼調用的任何函數都不會改變您的對象。 每次你在代碼中添加一個改變同一個對象的新位置時,跟踪該對象的外觀就變得越來越困難,這使得測試變得更加困難。

插圖:變異參數可能會導致問題

相反,如果您有一個接受對像或數組的函數,則讓它作用於該對像或數組,就好像它是只讀的一樣。 在代碼中創建一個新對像或數組,並根據您的需要為其添加值。 或者,在操作之前使用 Underscore 或 Lodash 克隆傳遞的對像或數組。 更好的是,使用像 Immutable.js 這樣的工具來創建只讀數據結構。

 // alters objects passed to it function upperCaseLocation(customerInfo) { customerInfo.location = customerInfo.location.toUpperCase(); return customerInfo; } // sends a new object back instead function upperCaseLocation(customerInfo) { return { name: customerInfo.name, location: customerInfo.location.toUpperCase(), age: customerInfo.age }; }

在編寫代碼之前編寫測試

在測試代碼之前編寫單元測試的過程稱為測試驅動開發 (TDD)。 許多開發人員發現 TDD 非常有用。

通過首先編寫測試,您被迫從使用它的開發人員的角度考慮您公開的 API。 它還有助於確保您只編寫足夠的代碼來滿足測試強制執行的合同,而不是過度設計一個不必要的複雜解決方案。

在實踐中,TDD 是一門很難為所有代碼更改提交的學科。 但是當它看起來值得嘗試時,它是保證您保持所有代碼可測試的好方法。

包起來

我們都知道在編寫和測試複雜的 JavaScript 應用程序時很容易陷入一些陷阱。 但希望通過這些技巧,並記住始終保持我們的代碼盡可能簡單和實用,我們可以保持較高的測試覆蓋率和較低的整體代碼複雜性!

有關的:
  • JavaScript 開發人員最常犯的 10 個錯誤
  • 速度的需求:頂級 JavaScript 編碼挑戰回顧展