編寫可測試的JavaScript代碼
無論我們使用和Node配合在一起的測試框架,例如Mocha或者Jasmine,還是在像PhantomJS這樣的無頭瀏覽器中運行依賴于DOM的測試,和以前相比,我們有更好的方式來對JavaScript進行單元測試。
然而,這并不意味著我們要測試的代碼就像我們的工具那樣容易!組織和編寫易于測試的代碼需要花費一些精力和并對其進行規(guī)劃,但是在函數(shù)式編程的啟發(fā)下,我們發(fā)現(xiàn)了一些模式,當(dāng)我們需要測試我們的代碼時,這些模式可以幫助我們避免那些“坑”。在這篇文章中,我們會查看一些有用的小貼士和模式,來幫助我們在JavaScript中編寫可測試的代碼。
保持業(yè)務(wù)邏輯和顯示邏輯分離
對于基于JavaScript的瀏覽器應(yīng)用程序來說,其中一項主要工作就是監(jiān)聽終端用戶觸發(fā)的DOM事件,然后通過運行一些業(yè)務(wù)邏輯并在頁面上顯示結(jié)果,以此對用戶做出反饋。在建立DOM事件監(jiān)聽器的地方,有時會誘惑你編寫一個匿名函數(shù)來完成所有這些工作。這樣帶來的問題是為了測試匿名函數(shù),你不得不去模擬DOM事件。這樣不僅會增加代碼行數(shù),而且會增加測試運行的時間。
與之相反,編寫一個有名字的函數(shù),然后將其傳給事件處理器。通過這種方式,你可以直接針對這個有名字的函數(shù)編寫測試用例,而不用去觸發(fā)一個假的DOM事件。
這不僅僅可以應(yīng)用到DOM上。在瀏覽器和Node上的很多API,都被設(shè)計成觸發(fā)和監(jiān)聽事件,或者等待其它類型的異步工作完成。憑經(jīng)驗說,如果你編寫了大量的匿名回調(diào)函數(shù),那么你的代碼可能不會容易被測試。
- // 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(', '));
- }
對異步代碼使用回調(diào)或者Promise
在上述的示例代碼中,我們經(jīng)過重構(gòu)的函數(shù)fetchThings會運行一個AJAX請求,以異步的方式完成了大部分工作。這意味著我們不能運行函數(shù)并測試它是否按照我們預(yù)期的那樣運行,因為我們不知道它什么時候運行完。
解決這個問題最常見的方法,是向函數(shù)中傳遞一個回調(diào)函數(shù)作為參數(shù),作為異步調(diào)用。這樣,在你的單元測試中,你可以在傳遞的回調(diào)函數(shù)中運行一些斷言。
另外一種常見并且越來越流行的組織異步代碼方法,是使用Promise API的方式。幸運的是,$.ajax和其它大部分jQuery的異步函數(shù)已經(jīng)返回了Promise對象,因此它已經(jīng)涵蓋了大部分常見的用例。
- // 很難測試;我們不知道AJAX請求會運行多長時間
- function fetchData() {
- $.ajax({ url: '/path/to/data' });
- }
- //可測試的;我們傳入一個回調(diào)函數(shù),然后在其中運行斷言
- function fetchDataWithCallback(callback) {
- $.ajax({
- url: '/path/to/data',
- success: callback,
- });
- }
- //同樣可測試的:在返回的Promise解析完后,我們可以運行斷言
- function fetchDataWithPromise() {
- return $.ajax({ url: '/path/to/data' });
- }
避免副作用
要編寫那些使用參數(shù)并且返回值僅僅依賴那些參數(shù)的函數(shù),就像將數(shù)字傳入數(shù)學(xué)公式,然后取得結(jié)果。如果你的函數(shù)依賴于一些外部的狀態(tài)(例如類實例的屬性或者某些文件的內(nèi)容),那么你在測試這個函數(shù)之前,就不得不去設(shè)置一些狀態(tài),在測試用例中需要更多的設(shè)置。你不得不去認為那些正在運行的代碼不會修改同一個的狀態(tài)。
同樣,你需要避免編寫那些會修改外部狀態(tài)的函數(shù),例如向文件寫入內(nèi)容或者向數(shù)據(jù)庫保存數(shù)據(jù)。這會避免一些副作用,來影響你測試其他代碼的能力。一般來說,***是將副作用和代碼控制在一起,讓“表面積”盡可能小。對于類和對象實例來說,類方法的副作用應(yīng)該被限制在被測試的類實例的范圍內(nèi)。
- // 很難測試;我們不得不設(shè)置一個globalListOfCars對象和一個名為#list-of-models的DOM結(jié)構(gòu),然后才能測試這段代碼
- function processCarData() {
- const models = globalListOfCars.map(car => car.model);
- $('#list-of-models').html(models.join(', '));
- }
- // 容易測試;我們傳遞一個參數(shù)然后測試它的返回值,而不需要設(shè)置任何全局變量或者檢查任何DOM結(jié)果
- function buildModelsString(cars) {
- const models = cars.map(car => car.model);
- return models.join(',');
- }
使用依賴注入
在函數(shù)中,有一種通用的模式,可以用來降低對外部狀態(tài)的使用,這就是依賴注入 —— 將函數(shù)的所有外部需要都通過函數(shù)參數(shù)的方式傳遞給函數(shù)。
- // 依賴于一個外部狀態(tài)數(shù)據(jù)連接實例;很難測試
- function updateRow(rowId, data) {
- myGlobalDatabaseConnector.update(rowId, data);
- }
- // 將數(shù)據(jù)庫連接實例作為參數(shù)傳遞給函數(shù);很容易測試。
- function updateRow(rowId, data, databaseConnector) {
- databaseConnector.update(rowId, data);
- }
使用依賴注入的一個主要好處,是你可以在單元測試中傳入mock對象,這樣就不會導(dǎo)致真的副作用(在這個例子中,就是更新數(shù)據(jù)庫行),你只需要斷言你的mock對象是按照期望的方式運行即可。
為每一個函數(shù)設(shè)置一個唯一的目的
將長函數(shù)分解成一系列小的、單一職責(zé)的函數(shù)。這樣我們可以更容易的去測試每一個函數(shù)是否是正確的,而不再希望一個大函數(shù)在返回結(jié)果之前就正確的做了所有的事情。
在函數(shù)式編程中,將幾個單一職責(zé)的函數(shù)拼在一起的行為稱作“組合”。Underscore.js甚至有一個名為_.compose的函數(shù),它將一個函數(shù)列表中的函數(shù)串在一起,將每一函數(shù)的返回結(jié)果作為輸入傳遞給下一個函數(shù)。
- // 很難測試
- 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);
- }
不要改變參數(shù)
在JavaScript中,數(shù)組和對象傳遞的是引用,而非值,因此它們是可變的。這意味著當(dāng)你將對象或者數(shù)組作為參數(shù)傳遞給函數(shù)時,你的代碼和使用你傳遞的對象或數(shù)組的函數(shù),都有能力去修改內(nèi)存中同一個數(shù)組或者對象。這意味著當(dāng)你測試你自己的代碼時,你必須信任所有你調(diào)用的函數(shù)中,沒有任何函數(shù)會修改你的對象。每當(dāng)你添加一些新的可以修改同一個對象的代碼時,跟蹤對象應(yīng)該是什么樣子就會變得越來越困難,從而更難去測試它們。
相反,當(dāng)你有一個函數(shù)需要使用對象或者數(shù)組時,你應(yīng)該在代碼中對待對象或者數(shù)組就像它們是只讀的。你可以根據(jù)需要創(chuàng)建新的對象或者數(shù)組,然后對齊填充。或者,使用Undersocre或者Lodash去對傳入的對象或者數(shù)組做一個拷貝,然后再對齊進行操作。更好的選擇是,使用一些像Immutable.js這樣的工具,去創(chuàng)建只讀的數(shù)據(jù)結(jié)構(gòu)。
- // 修改了傳入的對象
- function upperCaseLocation(customerInfo) {
- customerInfo.location = customerInfo.location.toUpperCase();
- return customerInfo;
- }
- // 返回了一個新的對象
- function upperCaseLocation(customerInfo) {
- return {
- name: customerInfo.name,
- location: customerInfo.location.toUpperCase(),
- age: customerInfo.age
- };
- }
在編碼之前先寫測試
在編碼之前先寫單元測試的過程被稱作測試驅(qū)動開發(fā)(TDD)。大量的開發(fā)者發(fā)現(xiàn)TDD非常有用。
通過先編寫測試用例,你就強迫自己從使用你代碼的開發(fā)者角度來考慮你要暴露的API,它還幫助你確保你只會編寫足夠的代碼來滿足測試用例的要求,而不要對解決方案“過度施工”,從而帶來不必要的復(fù)雜性。
在實踐中,TDD作為一條紀律,要覆蓋所有的代碼改動可能會比較困難。但是當(dāng)它看上去值得嘗試的時候,這就是一個很好的方式來保證你的所有代碼都是可測試的。
總結(jié)
在編寫和測試復(fù)雜的JavaScript應(yīng)用的時候,我們都知道有一些很容易遇到的“陷阱”,但我希望通過這些貼士和提醒,可以讓我們的代碼盡量簡單和函數(shù)化,我們可以做到讓測試覆蓋率很高,讓整體的代碼復(fù)雜性很低!