歡迎來到真實世界 - 也是需要來測一下傳說中的MVVM阿
在上一篇文章中,我們介紹了一個新的架構: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的設計:
https://gist.github.com/koromiko/1ef41d1d7d87beac22eaf4cc4a38ec69
可以看到,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:
https://gist.github.com/koromiko/62522e79c2c73cd2c4b92edf96d47e56
讓我們來好好看一下這個Mock。首先,它符合APIServiceProtocol,所以它完全能夠取代真正的APIService,被放到PhotoListViewModel裡面。接著,可以看到裡面有個property: isFetchPopularPhotoCalled,這個property預設是false,但會在APIServiceProtocol.fetchPopularPhoto被呼叫時變成true,這個設計的用意在於,我們可以透過這個isFetchPopularPhotoCalled,來知道fetchPopularPhoto這個function是不是真的有被呼叫到。
利用這個簡單的Mock,我們就可以來寫我們的第一個測試:
https://gist.github.com/koromiko/1165c644b0b575b96d7203cfc2fc17b4
這段程式碼翻成白話文就是:我想要知道,在sut.initFetch()之後,APIServiceProtocol. fetchPopularPhoto是否有被確實地執行。利用這個技巧,我們就可以測試ViewModel跟它的dependency objects之間的互動了。
Success or Failure?
除了測試我們的ViewModel是不是有確實呼叫fetchPopularPhoto之外,更重要的是,我們想知道當api request成功或失敗時,我們的PhotoListViewModel是不是有正確地處理這些狀況,也就是第二個需求:mock要能夠指定不同的狀態,來模擬真實的server行為。所以我們MockAPIService需要能夠聽從我們的指令,當我們希望它成功,它就要成功,當我們希望它失敗,它就要乖乖地失敗,這就是人在屋簷下,不得不低頭(可以這樣隨便亂用?)。
在這裡,我們先從test code開始看起。剛剛我們有個use case是這樣的:
- 網路層出錯的時候,要顯示錯誤訊息
根據這個case,我們可以寫出這樣的測試code:
https://gist.github.com/koromiko/37d772b81c62bae16c82bb13b9ed0f7a
我們會先觸發initFetch,讓PhotoListViewModel透過APIServiceProtocol上網去抓資料。然後在mockAPIService.fetchFail(error: error)這裡,我們將mockAPIService設定成一定會回傳失敗,並且指定好錯誤的類型。最後我們會驗證PhotoListViewModel是否有設定好對應的錯誤訊息。這個可以直接指定錯誤類型的mock讓你可以很輕易地模擬各種正確或錯誤情況,並且看看你的物件是不是正常功能中,是不是很方便呢?(是)
接著我們來看看這樣的mock要怎麼設計。先來回顧一下我們的PhotoListViewModel.initFetch():
https://gist.github.com/koromiko/26bd480bbaec34a5a5736a9a05318aec
initFetch會先啟動apiService的fetchPopularPhoto,並且設定好callback closure,等待apiService完成工作呼叫callback closure,再做對應的處理。所以我們如果在MockAPIService要模擬失敗的API request,就要從這個callback closure下手!
最後我們就實作出了這樣的MockAPIService:
https://gist.github.com/koromiko/ddce82f819d5ea941f99f25265af5788
從上面的程式碼可以看到,當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:
https://gist.github.com/koromiko/d5fa15c50eb3f3627cc6389202eb109f
這段test code代表的意思是,當使用者按下第一個cell時,我們要測試allowSegue是否為true。這段code有兩個問題:
- ViewModel在還沒initFetch之前都不會有資料,所以這樣會觸發exception
- 我們預設了IndexPath(row: 0, section: 0)的photo是for sale了,但事實上它是空的
這時候,stubs就可以派上用場了!Stubs在測試的設計上代表的是一些預先準備好的資料,詳細的定義可以再參考拙作(無孔不入吧!)。為了解決沒有資料,但是又不能真的連上server去取資料的狀況,我們必須要設計一些stubs來騙過我們的PhotoListViewModel。所以現在來幫我們的MockAPIService做點修改,讓它可以回傳設計好的stubs:
https://gist.github.com/koromiko/c619cbacb3d586e7dffe59277563cf20
其中completePhotos這個property就是我們放置stubs的地方,只要在這邊指定好Photo objects,在呼叫MockAPIService.fetchSuccess時,completeClosure就會把completePhotos裡面的內容回傳給PhotoListViewModel。再回到我們的test code,我們現在把test code修改成這樣:
https://gist.github.com/koromiko/1e145c3409b158f4fea4a174a789f64b
我們先把準備好的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