測試非同步 JavaScript
JavaScript 社群普遍存在一個迷思,認為測試非同步程式碼需要一種不同於測試「常規」同步程式碼的方法。在這篇文章中,我將說明為什麼通常情況並非如此。我將重點說明測試支援非同步行為的程式碼單元與內在非同步程式碼之間的差異。我也將展示基於 Promise 的非同步程式碼如何提供乾淨且簡潔的單元測試,這些測試可以用一種清晰且可讀的方式進行測試,同時仍然驗證非同步行為。
2013 年 9 月 18 日
相容非同步程式碼與內在非同步程式碼
我們身為 JavaScript 開發人員編寫的大部分「非同步」程式碼並非內在非同步。例如,我們來看一個使用 JQuery 實作的簡單 ajax 操作
var callback = function(){ alert('I was called back asynchronously'); }; $.ajax({ type: 'GET', url: 'http://example.com', done: callback });
你可能會看一看,然後說「那是非同步程式碼」。它有一個回呼函式,對吧?實際上它並非內在非同步,它只是支援在非同步背景下使用的可能性。由於 $.ajax
透過回呼函式,而非使用回傳值(或例外)將 AJAX 呼叫的結果提供給呼叫者,因此它能夠非同步地實作其操作,但事實上它可以選擇不這樣做。想像這個 $.ajax
的偽造版本
function ajax( opts ){ opts.done( "fake result" ); }
這顯然不是一個完整的 XHR 實作 ;) 但更重要的是,它也不是非同步實作。透過直接從方法實作內部呼叫 done
回呼,我們將一個潛在的非同步操作壓縮成一個完全同步的操作。這對 ajax
的用戶會產生什麼影響?如果我們這樣呼叫我們的假 ajax 方法
console.log( 'calling ajax...' ); ajax({ done: function(){ console.log( 'callback called' ); } }); console.log( '...called ajax' );
我們會看到類似以下的輸出
calling ajax... callback called ...called ajax
如您所見,我們的記錄條目是以定義順序寫入的,因為我們使用的假 ajax
函式是一個潛在非同步 API 的同步實作。所有這些程式碼都將由執行階段同步執行,在事件迴圈中單次執行 [1]。
思考這件事的另一種方式是,同步運作的方法呼叫是更通用的非同步案例的一個特例。同步程式碼只是在原始呼叫的內容中傳回結果的非同步程式碼。
測試支援非同步程式碼
希望我已經成功地證明我們編寫的大部分 JavaScript 程式碼並非本質上非同步,它只是支援非同步行為,因為它呼叫非同步能力的 API,也就是使用回呼(或承諾)的 API。但我們為什麼要關心這件事?我們編寫的程式碼將永遠在非同步內容中使用,對吧?嗯,不,當我們想要為我們的非同步支援程式碼撰寫單元測試時,情況並非如此。
讓我們繼續使用 $.ajax
的範例。想像一下我們正在撰寫一個函式,從 URL 中擷取目前使用者的 JSON 描述,並根據該 JSON 建立一個本機使用者物件。我們的實作可能如下所示
function parseUserJson(userJson) { return { loggedIn: true, fullName: userJson.firstName + " " + userJson.lastName }; }; function fetchCurrentUser(callback) { function ajaxDone(userJson) { var user = parseUserJson(userJson); callback(user); }; return $.ajax({ type: 'GET', url: "http://example.com/currentUser", done: ajaxDone }); };
在 fetchCurrentUser
中,我們對一個 url 發起 GET 要求,提供一個 ajaxDone
回呼,該回呼將在要求傳回回應後非同步執行。在該回呼中,我們從回應中取得我們傳回的 JSON,使用 parseUserJson
函式剖析它,以建立一個(相當貧血的)使用者網域物件。最後,我們呼叫最初傳遞給 fetchCurrentUser
的回呼,將使用者物件作為參數傳遞。
讓我們看看我們將如何為這段程式碼撰寫單元測試(這篇文章底部有一個在測試期間使用的工具和函式庫清單)。例如,我們將如何測試 fetchCurrentUser
將 JSON 剖析成適當的使用者物件?在了解內在非同步程式碼和非同步支援程式碼之間的區別之前,我們可能會認為我們需要某種非同步單元測試來測試這個非同步程式碼。但現在我們了解到我們正在測試的程式碼並非本質上非同步。我們可以將它的執行壓縮成一個同步流程以進行測試。讓我們看看它可能是什麼樣子
describe('fetchCurrentUser', function() { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(ajaxOpts) { var doneCallback = ajaxOpts.done; doneCallback(simulatedAjaxResponse); }; function fetchCallback(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); }; fetchCurrentUser(fetchCallback); }); });
我們首先做的是用一個存根函式取代 $.ajax
,它允許我們模擬 ajax 回應(直接搞亂 $.ajax
相當噁心,但我不想在這篇文章中深入依賴性管理,所以我們會忍耐)。然後我們呼叫 fetchCurrentUser
函式,它是我們測試的主題。由於 fetchCurrentUser
需要支援非同步擷取,因此它會使用回呼。我們在此測試中的目標是檢查呼叫 fetchCurrentUser
的最終結果,這表示我們需要提供一個回呼給它,它會接收最終建立的使用者物件。該回呼使用 Chai 的 expect 式斷言 來驗證使用者的 fullName
屬性是否正確初始化。
請務必注意,此測試將以完全同步的方式執行。就像我們的先前範例一樣,它將在事件迴圈的單一轉動中完成。
陷阱
此測試方法有一個問題。如果我們不小心在我們的生產程式碼中註解掉一個重要行,如下所示,會發生什麼事
function fetchCurrentUser(callback) { function ajaxDone(userJson) { var user = parseUserJson(userJson); //callback(user); }; return $.ajax({ type: 'GET', url: "http://example.com/currentUser", done: ajaxDone }); };
此函式按照目前的狀況無法正確運作。它永遠不會呼叫傳遞給 fetchCurrentUser
的回呼,表示永遠不會傳回使用者物件。您會預期我們的測試會驗證這一點,因為它會明確檢查使用者的 fullName
屬性的值。
不過,事實並非如此。測試將繼續通過。這是為什麼?嗯,我們將我們的斷言放入回呼中,而回呼永遠不會執行!這個錯誤表示我們的測試部分永遠不會被呼叫,因此永遠不會執行。
解決此問題的一個天真方法可能如下所示
describe('fetchCurrentUser', function() { it('creates a parsed user', function() { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(ajaxOpts) { var doneCallback = ajaxOpts.done; doneCallback(simulatedAjaxResponse); }; function fetchCallback(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); callbackCalled = true; }; var callbackCalled = false; fetchCurrentUser(fetchCallback); expect(callbackCalled).to.be.true; }); });
這是與之前相同的測試,只不過現在我們也會追蹤回呼是否曾經被呼叫。此測試現在會正確偵測到我們的回呼未被執行,並會失敗,但它有點笨拙。如果我們使用 Mocha 作為我們的測試執行器(例如,而不是 Jasmine),我們有一個稍微更好的選項
describe('fetchCurrentUser', function() { it('creates a parsed user', function(done) { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(ajaxOpts) { var doneCallback = ajaxOpts.done; doneCallback(simulatedAjaxResponse); }; function fetchCallback(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); done(); }; fetchCurrentUser(fetchCallback); }); });
請注意,我們的 it
區塊現在會使用 done
參數。如果 Mocha 看見您的 it
區塊預期一個參數,它會將測試視為非同步測試。它會提供一個 done
函式給您的測試,而您的測試必須呼叫 done()
來告訴 Mocha 測試已完成。呼叫 done()
是我們的測試標記其控制流程的結束。這表示 Mocha 能夠偵測測試是否已到達其控制流程的結束,並在未發生此情況時讓測試失敗。請注意,這也表示您的測試可以真正非同步,如果需要的話,並透過執行迴圈的多次轉動來執行。
這種明確指出以回呼為中心的測試程式碼已結束的簡單方法,是有些人偏好 Mocha 而非 Jasmine 的原因之一。Jasmine 也支援非同步測試,但其機制比 done()
函式笨拙許多。
不錯,但還不夠好
好,所以我們已經看到,為導向回呼的非同步支援程式碼撰寫單元測試非常有可能。然而,我必須承認,這些測試並不容易閱讀。感覺有很多管線程式碼,而程式碼以回呼為中心的本質會滲入我們的測試中。我認為這是一個經典案例,我們的測試有助於告知我們「程式碼異味」。如果測試看起來很糟糕或很難撰寫,那麼正在測試的程式碼設計方式可能有些問題。
接下來,我將論證從回呼切換到 Promise 將有助於我們減少這種程式碼異味,從而產生更簡潔的程式碼,並產生相應的令人愉快的測試。
Promise 的快速介紹
在本文的其餘部分,我將從導向回呼的非同步程式碼切換到導向承諾。我發現 Promise 會導致一種以宣告方式建模非同步支援程式碼控制流程的實作。同樣,我認為 Promise 使得更容易推論非同步支援程式碼的單元測試。我將在此簡要概述 Promise。如果您以前沒有使用過它們,那麼我強烈建議您先深入瞭解它們,作為使用它們來改善您自己的非同步程式碼的第一步。
我喜歡將 Promise 視為回呼的物件導向版本,並附加了一些額外功能。使用傳統的導向回呼程式碼,當您呼叫非同步函式時,您會傳入一個回呼,一旦非同步作業完成,就會呼叫該回呼。在那個函式呼叫中,您都在要求執行一些非同步工作,並指定工作完成後接下來要執行什麼。使用 Promise,非同步作業的要求與之後要執行的動作分開。您像以前一樣呼叫非同步作業,但呼叫者不會將回呼作為引數提供給非同步函式。相反,非同步函式會傳回一個 Promise 物件給呼叫者。然後,呼叫者會在該 Promise 上註冊一個回呼。您呼叫函式來呼叫非同步作業,然後您透過與函式傳回的 Promise 互動,分別說明您希望在作業完成後執行什麼。
因此,請勿使用這種導向回呼的程式碼
var callback = function(){ alert('I was called back asynchronously'); }; someAsyncFunction( "some", "args", callback );
您會使用導向承諾的程式碼執行此操作
var callback = function(){ alert('I was called back asynchronously'); }; var promise = someAsyncFunction( "some", "args" ); promise.done( callback );
在大多數情況下,您會使用 jQuery 風格的方法鏈結和匿名函式,產生類似於
someAsyncFunction( "some", "args" ).done( function(){ alert('I was called back asynchronously'); });
這只是 Promise 函式庫提供的非常基本的功用,還有更多可以利用的。我在 之前的部落格文章 中更詳細地介紹了 Promise。我鼓勵你閱讀該文章以了解更多資訊。該文章還包括一個更複雜的範例,說明 Promise 函式庫的一些更進階功能如何有助於從你的程式碼中移除一些繁瑣的非同步管道,有助於將重點放在實際解決的問題上。
Dominic Denicola 在 這篇文章 中也很好地解釋了為什麼 Promise 在中如此有用。強烈建議閱讀。
將我們的非同步程式碼移植到 Promise
在我們之前的範例中,我們有以下導向回呼的實作
function parseUserJson(userJson) { return { loggedIn: true, fullName: userJson.firstName + " " + userJson.lastName }; }; function fetchCurrentUser(callback) { function ajaxDone(userJson) { var user = parseUserJson(userJson); callback(user); }; return $.ajax({ type: 'GET', url: "http://example.com/currentUser", done: ajaxDone }); };
以下是導向承諾的實作看起來像什麼
function parseUserJson(userJson) { return { loggedIn: true, fullName: userJson.firstName + " " + userJson.lastName }; }; function fetchCurrentUser() { return Q.when($.ajax({ type: 'GET', url: "http://example.com/currentUser" })).then(parseUserJson); };
與之前一樣,fetchCurrentUser
對 url 發起 GET 要求,但我們並未將回呼直接傳遞給 $.ajax
函式,而是取而代之取得該函式傳回的承諾,並將 parseUserJson
函式串連到承諾上。透過這種方式,我們安排來自 $.ajax
呼叫的 JSON 回應流向我們的剖析器函式,在該函式中,它會轉換成剖析後的使用者物件,然後繼續流向呼叫者為 fetchCurrentUser
設定的任何進一步承諾管線。
請注意,我使用優秀的 Q 函式庫 來增強 JQuery 從 $.ajax(...)
呼叫傳回的不太完美的 $.Deferred
承諾實作。我先前引用的 Dominic 的文章 也更詳細地討論了 $.Deferred
中缺少的部分。
我發現這種導向承諾的實作比導向回呼的程式碼更容易閱讀,而且透過將原始回呼替換為承諾物件,擴充其運作方式的選項更多。我們可以取得類似 $.ajax
的承諾,然後建立在承諾上,以建構一個對值執行的操作管線,在值透過管線移動時轉換值。
這個導向承諾的實作的測試也應說明它會產生更易讀的程式碼
describe('fetchCurrentUser', function() { it('creates a parsed user', function(done) { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(){ return Q(simulatedAjaxResponse); } var userPromise = fetchCurrentUser(); userPromise.then(function(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); done(); }); }); });
這個測試在概念上與我們之前導向回呼的測試相同。我們將 $.ajax
替換為只傳回硬式編碼模擬回應的假實作,並將其包裝在預先解析的 Q 承諾中。然後我們呼叫 fetchCurrentUser
函式。最後,我們驗證從承諾管線另一端輸出的內容具有適當的 .fullName
屬性。
我認為這種導向承諾的形式更容易閱讀且更容易重構。但還有更多!由於承諾充當非同步操作的封裝,我們還可以透過類似 chai-as-promised 的優秀函式庫來增強我們的測試執行器,這將讓我們可以將我們的測試程式碼重構為
describe('fetchCurrentUser', function() { it('creates a parsed user', function(done) { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(){ return Q(simulatedAjaxResponse); } var fetchResult = fetchCurrentUser(); return expect( fetchResult ).to.eventually .have.property('fullName','Tomas Jakobsen') .notify(done); }); });
我們可以進一步探討。透過加入 mocha-as-promised,我們不再需要明確地告訴 Mocha 我們的測試控制流程何時結束
describe('fetchCurrentUser', function() { it('creates a parsed user', function() { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(){ return Q(simulatedAjaxResponse); } var fetchResult = fetchCurrentUser(); return expect( fetchResult ).to.eventually .have.property('fullName','Tomas Jakobse'); }); });
這裡我們已經擺脫了 done
函式的技巧。相反地,我們將表示測試非同步控制流程的 Promise 鏈傳遞 回 Mocha 測試執行器,其中 mocha-as-promised 擴充功能能夠在背景中處理,確保 Promise 鏈在繼續進行下一個測試之前結束。這是一個非常聰明的功能,它內建於其他測試執行器中,例如 Buster。
這是一個很好的例子,說明了為什麼 Promise 如此強大。透過實體化非同步控制流程的概念,我們實際上可以將控制流程傳遞回測試執行器,然後它可以直接對該控制流程進行操作。至關重要的是,我們的測試實際上不需要知道框架在做什麼。它們只將控制流程傳遞回呼叫者並讓它接手。
基本上,Promise 允許我們將調用操作的考量與處理該操作結果的考量分開。這表示我們可以讓我們的測試程式碼模擬一半,並測試我們的製作程式碼如何處理另一半。
測試內在非同步程式碼
我已經說明了我們可以以基本上同步的方式測試支援非同步的程式碼,但是如果你想要測試真正本質上是非同步的程式碼呢?
Mocha 支援非同步測試,表示使用我展示過的相同技術,可以測試真正非同步的程式碼,這技術用於以同步方式測試支援非同步的程式碼。不過,一般 JavaScript 程式碼很少會是內建非同步的。內建非同步表示程式碼明確放棄事件迴圈的執行順序,例如呼叫 setTimeout
,或是呼叫原生非封鎖 API,例如 XMLHttpRequest
。我們寫程式碼時,有多常會直接這樣做?我想應該不常[2]。我們會整合與此類程式碼,例如 JQuery 等函式庫,但正如我在這篇文章中展示的,我們仍能以同步方式測試整合,因為整合程式碼並非內建非同步。
積極限制撰寫和維護的真正非同步測試數量
我敢說,除非撰寫低階函式庫,否則很少需要對內建非同步程式碼進行單元測試。你可能需要測試與內建非同步函式庫互動的程式碼,但你不會經常自己建立此類程式碼,因此也就不常需要為此類程式碼撰寫測試。事實上,我的建議是積極限制撰寫和維護的真正非同步測試數量。Martin Fowler 的非決定性測試的精彩文章徹底說明了為何此類測試往往會損害測試套件的整體健全性。
如果你確實發現自己撰寫了大量內建非同步程式碼,那麼你可能需要退一步,找出程式碼中一個小而可控的區域,用來處理所有那些麻煩的非同步事項。這類程式碼難以撰寫,也難以測試。將其隔離,並使用不同類型的測試(例如整合測試)徹底測試。Gerald Meszaros 的Humble Object 模式文件提供了一些方法,說明如何以乾淨的方式隔離真正非同步程式碼。另一個關於測試和封裝此類棘手程式碼的絕佳建議來源是GOOS 書籍,其中詳細說明了不同層級的非同步程式碼測試。
最後,我猜測大多數需要非同步功能的 JavaScript「單元測試」,實際上都是呼叫資料庫、DOM、Web API 等的高階整合測試。此類測試很好且有價值,但了解它們是不同類型的測試,而且你幾乎可以肯定會從更多孤立單元測試的大型套件中獲得更好的價值,這一點很重要。不過,這是另一天的文章主題。
版權頁:使用的工具和函式庫
在我的程式碼範例中,我使用了Q promises實作。在測試方面,我使用了Mocha 測試執行器,搭配Chai 測試斷言函式庫。我使用mocha-as-promised和chai-as-promised函式庫強化了這個測試設定。我在node.js上執行測試,使用npm來宣告和安裝上述工具和函式庫。
所有測試程式碼都與任何 DOM 需求隔離,也不需要 JQuery(因為我們總是會用測試替身取代 $.ajax
)。
腳註
1: 持續非同步
即使你可以在內嵌非同步作業中解決問題,仍有強而有力的論點認為,你應該延遲回應到事件迴圈的後續轉換,以維持一致的呼叫順序(有關這方面的更多資訊,請參閱下一個腳註!)執行呼叫函式的延遲通常會使用 setImmediate
或類似方法來達成。
2: Promises/A+ 規格相容性
我狡猾地忽略了符合 Promises/A+ 的承諾實作(例如 Q)的事實 - 必須在 事件迴圈的單一轉換內解決。
如果你不知道這代表什麼意思,請不要太擔心。如果你知道,請原諒我沒有深入探討這個問題。我會主張,即使承諾導向的程式碼在兩次轉換中完成,而不是一次轉換,在概念上仍會扁平化為同步順序。
重大修訂
2013 年 9 月 18 日:第二版,新增承諾的涵蓋範圍
2013 年 9 月 3 日:發布第一版