最近,在做項目時掉進了 AngularJS 異步調用 $q
測試的坑中,直接躺槍了。折騰了許久日子,終於想通了其中的道道,但並不確定是最佳的解決方案,最後還是決定總結成文以求能與其它的園友共同分享以求找到更好的解決方案。
首先,我的測試環境是 [Karma|http://karma-runner.github.io/0.12/index.html] + [Jasmine|http://jasmine.github.io/] ,這屬於 AngularJS的其中一種配置,也是AngularJS官方所推薦的框架,Jasmine 用起來也確實很不錯。
很多的spec都沒有什麼大問題,只是當我為其中的幾重要的異步處理服務編寫測試時就出事了,代碼在實際運行環境中是能正常運行的,但在測試中卻不能通過,這肯定是測試沒寫好。在網上google了多天,也對各種方案進行嘗試一直也沒有找到解決方法,然而這種問題不會是特例,而是經常會遇到的,那就是在Angular服務中返回的 promise
很難進行測試。
從代碼入手會更容易了解問題的始末:
jasmine 的異步測試模式是實現一種簡單的超時機制,通過等待 done()
方法對計時器重置,當在超時限制內(默認5s) done()
沒有被調用則會引發測試失敗的異常。在 1.3 之前是采用 runs
和 waitsFor
方法進行處理,在2.0後這兩個方法被簡化去除掉了,只能用 done
,這裡就以我們最經常會用到的 FileAPI 中的 FileReader 來做實例,FileReader 對文件對象(Blob)的讀取是一個異步方法,那麼將這個實現邏輯直接寫在 jasmine 中應該是這樣的:
describe '異步調用測試', ->
beforeEach module 'tdd'
it 'Blob內的數據應該被讀取為文本', (done)->
expected_text = chance.sentence() # 用 chance 產生隨機的字符串
blob = new Blob([expected_text])
reader = new FileReader()
reader.onloadend = (e)->
expect(e.target.result).toBe expected_text
done()
reader.readAsText blob
測試結果是 pass , 這只是為了試用一下 jasmine 中 done
的效果。當然在項目中這樣做是完全沒有意義的,這只是一個引子,我會分三步來完整這個測試。
接下來是將這個實現邏輯封裝成為 AngularJS的 service。由於是異步處理所以這個 service 應該是返回一個 promise
對象。 為了更具體地說明這個問題,這裡只建立一個空白的 fileReader
服務,此服務只為了測試 then
的觸發時機:
'use strict'
fileReader=($q)->
(_blob)->
deferred=$q.defer()
deferred.resolve('馬上返回')
deferred.promise
angular.module('tdd').service 'fileReader', fileReader
那麼前文的測試就應該修改為:
describe '異步調用測試' ,->
beforeEach module 'tdd'
_fileReader={}
it '應該通過fileReader 服務從Blob 對象中讀出文本', (done)->
expected_text = '馬上返回'
blob = new Blob([expected_text])
fileReader(blob).then (actual_text)->
expect(expected_text).toEqual actual_text
done()
問題開始來了,這個測試運行的結果是 Fail! 並且得到以下的提示:
Error: Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.
超時!也就是說 由於then
並沒有被調用,所以超時返回方法 done()
沒有被執行而直接出現這個測試錯誤。然而諷刺的是,fileReader 這個服務在浏覽器內是可以直接運行而不會產生任何的錯誤的。
問題出在哪裡 ?找了許久,最終發現,由於在 jasmine 中的環境是被 mock 出來的,是由 ngMock 對 angular 的對象和brower對象內的服務進行了重新的模擬,這個會與實際的運行有些許的差異,由其要使 then
方法被正確調用那需要在返回then
之後調用 $rootScope.$apply()
(這個內容可以直接參考:https://docs.angularjs.org/api/ng/service/$q) 也就是說,我們並不需要直接使用 jasmine 提供的“阻塞”模擬,而是直接用 $rootScope.$apply()
讓異步方法直接返回。
describe ' 異步調用測試' , ->
describe module 'tdd'
it '應該通過fileReader 服務從Blob 對象中讀出文本',$inject (fileReader,$rootScope)->
expected_text='馬上返回'
blob=new Blob([expected_text])
actual_text=''
fileReader(blob).then (data)->
actual=data
$rootScope.$apply()
expect(expected_text) .toEqual actual_text
這一次測試 pass
了。既然測試寫好了,那麼我們就回到最初那裡,將實現邏輯真正地加入到 fileReader
服務中:
'use strict'
fileReader=($q)->
(_blob)->
deferred=$q.defer()
reader = new FileReader()
reader.onloadend=(e)->
# 注意:事件觸發實質上等同於異步回調
deferred.resolve e.target.result
deferred.promise
angular.module('tdd').service 'fileReader', fileReader
同樣地,運行剛才寫好的異步測試程序。 當然在運行前要修改一下 expected_text
為 expected_text = chance.sentence()
。然而,運行結果是讓人失望的:
Chrome 39.0.2171 (Mac OS X 10.10.2) 異步調用測試 應該通過fileReader 服務從Blob 對象中讀出文本 FAILED
Expected 'Gipik ejfim renma fanibi nub otu nihwojtat il bepu koufo dibe ohepusaw monumba.' to equal ''.
Error: Expected 'Gipik ejfim renma fanibi nub otu nihwojtat il bepu koufo dibe ohepusaw monumba.' to equal ''.
at Object.<anonymous> (/Users/Ray/code/tdd/test/spec/file_reader.service.spec.js:40:36 <- file_reader.service.spec.coffee:48:28)
at Object.invoke (/Users/Ray/code/tdd/bower_components/angular/angular.js:4182:17)
at Object.workFn (/Users/Ray/code/tdd/bower_components/angular-mocks/angular-mocks.js:2350:20)
很明顯,then
方法又無法觸發了,deferred.resolve
並沒有如我們預期那般在文件讀取時調用,而且 $rootScope.$apply() 也貌似失效了。在此時真正的問題才開始顯現:
當
resolve
是在其它的(非Angular Mock)異步調用中返回時$rootScope.$apply()
是無法正確觸發then
的。
我的實際項目比這個要更為復雜,因為我的實際的服務是操控 indexedDB的,眾所周知 indexedDB 裡一切都是異步的,所以他們在測試中無一通過!
就是為了這個問題我折騰了好久,最好還是將視線落在 ngMock
上。
[ngMock|https://docs.angularjs.org/api/ngMock] 這個模塊上只提供了最簡單的三種異步服務 $httpBackend
、$tiimeout
和 $interval
,正是因為他們的存在,在我們的測試中可以正常調用 $http
、$resource
等的常規異步服務。 然而, FileAPI, IndexedDB等這些 HTML5內的高等服務並沒有提供 mock。當時我的測試初衷並不想mock而是期往能實際地調用,而然這種想法貌似不太容易實現,加之,自我看了 ["Mocking Dependencies AngularJS Tests"|http://www.sitepoint.com/mocking-dependencies-angularjs-tests/] 一文後,更加確定了我的想法。
我的結論是,如果在自定義的 Angular服務中返回的 promise
是在 Angular的 scope內調用 resolve
那麼我們直接使用前面第二種測試方式就可以了,但如果 service 是包裝了其它的依賴服務,如FileReader 、IndexedDB、WebSQL或其它的以異步方式為主的服務那麼就只能通過 mock 來解決測試的問題,要不就不使用 $q
而采用 callback 方式將回調方法直接傳遞給第三方依賴服務(我現在的IndexedDB服務就是這種方式)。
以本文中所提及的 FileReader
為例的話,要測試通過那可以自己寫一個 mockFileReader
,通過 jasmine 的 spyOn
方法截取方法調用:
beforeEach(function () {
// Mock FileReader
MockFileReader = {
readAsDataURL: function (file) {
if (file === 'file') {
this.result = 'readedFile';
this.onload();
} else if (file === 'progress') {
this.onprogress({total: 70, loaded: 30});
} else {
this.result = 'fileError';
this.onerror();
}
},
readAsText: function (file, encoding) {
if (file === 'file') {
this.result = 'readedFile';
this.onload();
} else if (file === 'progress') {
this.onprogress({total: 70, loaded: 30});
} else {
this.result = 'fileError';
this.onerror();
}
}
};
spyOn(MockFileReader, 'readAsDataURL').and.callThrough();
spyOn(MockFileReader, 'readAsText').and.callThrough();
// 將 MockFileReader 掛到 window 中
$window = {
FileReader: jasmine.createSpy('FileReader').and.returnValue(MockFileReader)
};
笨一點的做法就是對有使用的第三方依賴都編寫一個 Mock 加以取代,更快捷的方法就是看看誰已經將這個“輪子”發明了而不用我們重新造一次。
一些AngularJS相關文章鏈接:
AngularJS權威教程 清晰PDF版 http://www.linuxidc.com/Linux/2015-01/111429.htm
希望你喜歡,並分享我的工作~帶你走近AngularJS系列:
如何在 AngularJS 中對控制器進行單元測試 http://www.linuxidc.com/Linux/2013-12/94166.htm
在 AngularJS 應用中通過 JSON 文件來設置狀態 http://www.linuxidc.com/Linux/2014-07/104083.htm
AngularJS 之 Factory vs Service vs Provider http://www.linuxidc.com/Linux/2014-05/101475.htm
AngularJS —— 使用 ngResource、RESTful APIs 和 Spring MVC 框架提交數據 http://www.linuxidc.com/Linux/2014-07/104402.htm
AngularJS 的詳細介紹:請點這裡
AngularJS 的下載地址:請點這裡