在上一篇文章中,我們介紹了一個新的架構:Model-View-ViewModel(MVVM)。透過MVVM pattern,我們把business logic跟presentational logic從ViewController裡面抽出來,變成一個單純好測試的物件。但是對於如何做測試,卻是支字不提,不要懷疑,這就是拖稿(?)。在這篇文章裡,我們就要來看看,怎樣針對我們的ViewModel來寫Unit Test。
最近APP架構又成為大家熱門的話題,有很多有趣的文章都在針對這種百家爭鳴的iOS app架構現象提出檢討。其中Much ado about iOS app architecture這篇還不錯,大家可以看看。雖然像是MVVM、VIPER、Clean這些架構的目地都是要解決app架構上權責不分、不易測試等等問題,但是很容易被當成Silver Bullet,以為只要套用了這樣的全新架構,code從此就變得閃亮亮,bug也都自然消失了。另一方面,也有很多人因為這些架構的某些挶限,而完全否認這些架構所帶來的好處(簡單、易上手等等)。
在軟體的世界,真的沒有所謂的好壞,只有適不適合。對IQ不高的小蛇我來說,在沒有人手把手地教你的情況,跟本很難在短時間達到這些人說的MVC好棒棒的境界,有太多Pattern、太多的法則要去熟練,更不用說要得心應手地應用了。MVVM的好處是它相對簡單很多,並且要從既有的code改寫成MVVM也不是非常難的事情,MVVM在從0開始的情況下,就非常有價值。但如果團隊神人很多,有老練的架構師或是資深工程師在天天幫你看code,MVVM就不一定適合你,團隊原本在用的架構跟原則反而才會是最適合的。
Btw, 為了寫文章,小蛇我每個月都要逼自己至少會看一部電影,才有辦法寫出心得來(甚麼理由)。所以想看電影推薦的朋友,不要遲疑,直接End就對了!(也太快放棄抵抗)
TL; DR
在這篇文章中,我們會提到兩個測試的小技巧:
- 如何設計mock來模擬不同的網路狀況
- 如何利用stub建立能夠被測試的資料狀態
利用這兩個技巧,我們可以幫我們的ViewModel建立很完整的測試。
A simple gallery app
回顧一下上次我們的simple gallery app,它具有以下的功能:
- 會從500px API抓取熱門相片,並且把相片排成列表show出來,每張相片都會顯示標題、描述、跟拍攝日期。
- 如果使用者點選了非賣品,app就不會讓使用者進到下一頁,並且跳出錯誤訊息。
我們把最一開始的這個頁面稱作PhotoList,它的互動流程會像下面這樣:
其中APIService負責網路層的溝通,像是設定URL、設定request body等等。而PhotoListViewModel則會跟APIService要資料,並且把要到的資料整理一下,轉換成能夠讓View綁定的各種interfaces,也會接收使用者的動作,做出相對應的反應。PhotoListViewController就是單純的View,負責將ViewModel的資料在View上面呈現出來。
在這篇文章裡,我們將會針對三個不同的use cases做測試:
- 要能啟動APIService上網抓資料
- 網路層出錯的時候,要顯示錯誤訊息
- 當使用者點擊for sale的照片時要允許跳到下一頁
MVVM and Dependency Injection
回顧一下我們PhotoListViewModel的設計:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class PhotoListViewModel { | |
let apiService: APIServiceProtocol | |
init( apiService: APIServiceProtocol = APIService()) { | |
self.apiService = apiService | |
} | |
func initFetch() { | |
self.isLoading = true | |
apiService.fetchPopularPhoto { [weak self] (success, photos, error) in | |
self?.isLoading = false | |
if let error = error { | |
self?.alertMessage = error.rawValue | |
} else { | |
self?.processFetchedPhoto(photos: photos) | |
} | |
} | |
} | |
} |
可以看到,apiService這個物件負責跟server拿資料,並且將拿到的資料回傳給PhotoListViewModel使用。我們利用Dependency Injection(DI)的技巧,將跟網路層有關的工作,全部都交給apiService去做。這個apiService在跑正式的code時,會放上真的APIService物件,讓它真的上server去抓資料。另一方面,當我們在跑測試時,就用被換成假的MockAPIService物件。這樣的好處是在跑測試時,除了可以不用真的把request打上server之外,我們也可以利用MockAPISerivce來看看我們的PhotoListViewModel是不是真的有正確地工作。它們之間的關係可以用底下這張圖來理解:
對DI不熟的話,就讓小蛇來業配一下(自己業配自己?),可以參考拙作歡迎來到真實世界 – Unit Test for Networking,裡面有詳細的DI技巧介紹。
Behavior test
所以我們的MockAPIService需要滿足下列兩個需求:
- 要能確定PhotoListViewModel是否真的呼叫了某隻function
- 要能夠指定不同的狀態,來模擬真實的server行為
先來看需求1.,針對需求1.,我們可以做出這樣的Mock:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class MockApiService: APIServiceProtocol { | |
var isFetchPopularPhotoCalled = false | |
func fetchPopularPhoto(complete: @escaping (Bool, [Photo], APIError?) -> ()) { | |
isFetchPopularPhotoCalled = true | |
} | |
} |
讓我們來好好看一下這個Mock。首先,它符合APIServiceProtocol,所以它完全能夠取代真正的APIService,被放到PhotoListViewModel裡面。接著,可以看到裡面有個property: isFetchPopularPhotoCalled,這個property預設是false,但會在APIServiceProtocol.fetchPopularPhoto被呼叫時變成true,這個設計的用意在於,我們可以透過這個isFetchPopularPhotoCalled,來知道fetchPopularPhoto這個function是不是真的有被呼叫到。
利用這個簡單的Mock,我們就可以來寫我們的第一個測試:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
func test_fetch_photo() { | |
// When start fetch | |
sut.initFetch() | |
// Assert | |
XCTAssert(mockAPIService!.isFetchPopularPhotoCalled) | |
} |
這段程式碼翻成白話文就是:我想要知道,在sut.initFetch()之後,APIServiceProtocol. fetchPopularPhoto是否有被確實地執行。利用這個技巧,我們就可以測試ViewModel跟它的dependency objects之間的互動了。
Success or Failure?
除了測試我們的ViewModel是不是有確實呼叫fetchPopularPhoto之外,更重要的是,我們想知道當api request成功或失敗時,我們的PhotoListViewModel是不是有正確地處理這些狀況,也就是第二個需求:mock要能夠指定不同的狀態,來模擬真實的server行為。所以我們MockAPIService需要能夠聽從我們的指令,當我們希望它成功,它就要成功,當我們希望它失敗,它就要乖乖地失敗,這就是人在屋簷下,不得不低頭(可以這樣隨便亂用?)。
在這裡,我們先從test code開始看起。剛剛我們有個use case是這樣的:
- 網路層出錯的時候,要顯示錯誤訊息
根據這個case,我們可以寫出這樣的測試code:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
func test_fetch_photo_fail() { | |
// Given a failed fetch with a certain failure | |
let error = APIError.permissionDenied | |
// When | |
sut.initFetch() | |
mockAPIService.fetchFail(error: error ) | |
// Sut should display predefined error message | |
XCTAssertEqual( sut.alertMessage, error.rawValue ) | |
} |
我們會先觸發initFetch,讓PhotoListViewModel透過APIServiceProtocol上網去抓資料。然後在mockAPIService.fetchFail(error: error)這裡,我們將mockAPIService設定成一定會回傳失敗,並且指定好錯誤的類型。最後我們會驗證PhotoListViewModel是否有設定好對應的錯誤訊息。這個可以直接指定錯誤類型的mock讓你可以很輕易地模擬各種正確或錯誤情況,並且看看你的物件是不是正常功能中,是不是很方便呢?(是)
接著我們來看看這樣的mock要怎麼設計。先來回顧一下我們的PhotoListViewModel.initFetch():
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
func initFetch() { | |
self.isLoading = true | |
apiService.fetchPopularPhoto { [weak self] (success, photos, error) in | |
self?.isLoading = false | |
if let error = error { | |
self?.alertMessage = error.rawValue | |
} else { | |
self?.processFetchedPhoto(photos: photos) | |
} | |
} | |
} |
initFetch會先啟動apiService的fetchPopularPhoto,並且設定好callback closure,等待apiService完成工作呼叫callback closure,再做對應的處理。所以我們如果在MockAPIService要模擬失敗的API request,就要從這個callback closure下手!
最後我們就實作出了這樣的MockAPIService:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class MockAPIService: APIServiceProtocol { | |
var completeClosure: ((Bool, [Photo], APIError?) -> ())! | |
func fetchPopularPhoto(complete: @escaping (Bool, [Photo], APIError?) -> ()) { | |
isFetchPopularPhotoCalled = true | |
completeClosure = complete | |
} | |
func fetchSuccess() { | |
completeClosure( true, [Photo](), nil ) | |
} | |
func fetchFail(error: APIError?) { | |
completeClosure( false, [Photo](), error ) | |
} | |
} |
從上面的程式碼可以看到,當MockAPIService.fetchPopularPhoto被呼叫時,會先把callback closure存下來:
completeClosure = complete
等到MockAPIService.fetchSuccess或MockAPIService.fetchFail被呼叫時,才會觸發callback。在我們還沒呼叫fetchSuccess或fetchFail之前,PhotoListViewModel的callback是不會被呼叫的。這樣就完成了一個簡單的非同步、不同狀態的模擬。這樣的效果就如同上面的test code一樣,我們可以透過呼叫fetchFail並指定error object來模擬api request失敗的狀況。
Stubs for ViewModel
我們的ViewModel,除了提供各種properties讓View作資料的綁定之外,也提供接口讓View能夠把使用者的行為傳回來,並且做出相對應的改變,來讓View產生變化。這樣的行為,我們要怎樣做測試呢?一樣,我們先從use case開始看起:
- 當使用者點擊for sale的照片時要允許跳到下一頁
依照上面的use case,我們寫了以下的test code:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
func test_user_press_for_sale_item() { | |
//Given a sut with fetched photos | |
let indexPath = IndexPath(row: 0, section: 0) | |
//When | |
sut.userPressed( at: indexPath ) | |
//Assert | |
XCTAssertTrue( sut.isAllowSegue ) | |
} |
這段test code代表的意思是,當使用者按下第一個cell時,我們要測試allowSegue是否為true。這段code有兩個問題:
- ViewModel在還沒initFetch之前都不會有資料,所以這樣會觸發exception
- 我們預設了IndexPath(row: 0, section: 0)的photo是for sale了,但事實上它是空的
這時候,stubs就可以派上用場了!Stubs在測試的設計上代表的是一些預先準備好的資料,詳細的定義可以再參考拙作(無孔不入吧!)。為了解決沒有資料,但是又不能真的連上server去取資料的狀況,我們必須要設計一些stubs來騙過我們的PhotoListViewModel。所以現在來幫我們的MockAPIService做點修改,讓它可以回傳設計好的stubs:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class MockApiService: APIServiceProtocol { | |
var completePhotos: [Photo] = [Photo]() // Array of stubs | |
var completeClosure: ((Bool, [Photo], APIError?) -> ()) | |
func fetchPopularPhoto(complete: @escaping (Bool, [Photo], APIError?) -> ()) { | |
\\…\\ | |
completeClosure = complete | |
} | |
func fetchSuccess() { | |
completeClosure( true, completePhotos, nil ) // Return stubs instead of empty array | |
} | |
\\….\\ | |
} |
其中completePhotos這個property就是我們放置stubs的地方,只要在這邊指定好Photo objects,在呼叫MockAPIService.fetchSuccess時,completeClosure就會把completePhotos裡面的內容回傳給PhotoListViewModel。再回到我們的test code,我們現在把test code修改成這樣:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
func test_user_press_for_sale_item() { | |
let indexPath = IndexPath(row: 0, section: 0) | |
//Given some photo stubs | |
mockAPIService.completePhotos = StubGenerator().stubPhotos() | |
sut.initFetch() // Fetch stubs | |
mockAPIService.fetchSuccess() | |
//When User press a specific cell (a for sale photo stub) | |
sut.userPressed( at: indexPath ) | |
//Assert | |
XCTAssertTrue( sut.isAllowSegue ) | |
} |
我們先把準備好的Stubs:StubGenerator().stubPhotos(), 丟到mockAPIService.completePhotos裡面,接著觸發PhotoListViewModel的initFetch,這時候PhotoListViewModel會去跟mockAPIService要資料,然後我們再透過呼叫mockAPIService.fetchSuccess(),來讓stubs回傳給PhotoListViewModel,完成資料的準備。
這樣一來,我們在test code裡面呼叫sut.userPressed(at: indexPath)就沒有問題了,因為這時候PhotoListViewModel的狀態就會是已經截取完資料,並且因為stubs是我們在測試時一併放進去的,所以我們也知道我們正在測試的photo是不是for sale了。
More tests and more todos
這個小app還有更多的測試,有興趣的可以參考小弟的原始碼:
Tutorial/MVVMPlayground at master · koromiko/Tutorial · GitHub
裡面包含了這些test case:
- 要能啟動APIService上網抓資料
- 抓資料的時候要正確顯示讀取動畫
- 網路層出錯的時候,要顯示錯誤訊息
- cell數量要正確
- cell內容要正確
- 使用者點擊for sale的照片時要允許跳到下一頁
- 使用者點擊not for sale的照片時不能有動作並且要顯示錯誤訊息
可以看到,在MVVM的架構底下,我們寫的測試可以幾乎涵蓋整個模組,包括presentational logic還有各種複雜的state都能夠被測試到。這就是MVVM的好處,它容易上手並且不會有太多的boilerplate。
相對的,我們的這個測試小app也有很多待改善的點,像是View這一層完全沒測試,ViewModel的工作太多,還有關於ViewModel倒底應該stateless還是要有完整的state以方便測試,這些點都是未來可以改進的目標。這個系列未來會一步一步地refactor這個小app,歡迎訂閱小蛇的blog,一起來研究怎樣寫出更棒的app吧!
Recap
在這個簡單的分享裡面,我們透過設計好的MockAPIService,來模擬各種現實生活中會發生的情形,並且讓測試的code能夠完整涵蓋各種狀況。這個MockAPIService的任務主要有:
- 記錄SUT是否有確實與它互動
- 記錄SUT傳進去的資料是否正確
- 模擬各種不同的狀態
透過這個Mock,加上MVVM把presentational logic從ViewController裡面拆分出來的特性,我們就可以成功地完成所有use case的測試了!
還是要強調,沒有silver bullet,到這邊只是一開始而已,小蛇我也還在學習當中!歡迎大大們給予各種建議,也歡迎加入討論,覺得那邊觀念有錯或是程式有錯也歡迎提出來喔!
我的FB都在喇賽XD,所以想看技術相關的,請follow小蛇的Twitter: https://twitter.com/KoromikoNeo
最後進入本文XD
從很小的時候開始,這個畫面就是心中未來世界的代表,如果你問我未來世界長怎樣,我就會照著這個場景描述給你聽。到現在我還是能夠回想第一次看到這個場景設定的感動,也因為這部電影讓我開始喜歡cyberpuck,覺得那種用舊技術銓釋的未來十分迷人。
這部就是1982年的Blade Runner(銀翼殺手)
不過這部片在對話、敘事、還有邏輯的處理真的有待加強,bug超多,角色刻畫不深,還有很多如果沒有後人的解釋跟本連想像空間都沒有的晦澀對白,相較之下更單調的2001 Space Odessey反而在說故事方面略勝n籌XD
所以小蛇真正推薦的是Blade Runner 2049 XD
完全可以撐起原作甚至以說故事的能力來說還比前作強大,雖然以現代的眼光來看這種到處都是刻意雕琢的畫面及劇本,還有已經不算前衛影像風格可能已經不那麼吃香,但是因為它是Blade Runner的續作,所以看起來只有滿滿的感動 (已經無法中立評論XD) 總之請在看2049之前一定要看過1982或至少知道前作的故事,然後喜愛cyberpunk的記得去電影院看,雖然說現在只剩台北冷門時段有了XD
這部在IMDB拿到8.4高分的電影在票房上倒是有點悽慘,實在很可惜,科幻片在這個年代已經是復古的存在了XDDD