歡迎來到真實世界 – 也是需要來測一下傳說中的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建立很完整的測試。

回顧一下上次我們的simple gallery app,它具有以下的功能:

  1. 會從500px API抓取熱門相片,並且把相片排成列表show出來,每張相片都會顯示標題、描述、跟拍攝日期。
  2. 如果使用者點選了非賣品,app就不會讓使用者進到下一頁,並且跳出錯誤訊息。

我們把最一開始的這個頁面稱作PhotoList,它的互動流程會像下面這樣:

MVVM.001-1.png

其中APIService負責網路層的溝通,像是設定URL、設定request body等等。而PhotoListViewModel則會跟APIService要資料,並且把要到的資料整理一下,轉換成能夠讓View綁定的各種interfaces,也會接收使用者的動作,做出相對應的反應。PhotoListViewController就是單純的View,負責將ViewModel的資料在View上面呈現出來。

在這篇文章裡,我們將會針對三個不同的use cases做測試:

  1. 要能啟動APIService上網抓資料
  2. 網路層出錯的時候,要顯示錯誤訊息
  3. 當使用者點擊for sale的照片時要允許跳到下一頁

MVVM and Dependency Injection

回顧一下我們PhotoListViewModel的設計:

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是不是真的有正確地工作。它們之間的關係可以用底下這張圖來理解:

MVVM.001.png

對DI不熟的話,就讓小蛇來業配一下(自己業配自己?),可以參考拙作歡迎來到真實世界 – Unit Test for Networking,裡面有詳細的DI技巧介紹。

Behavior test

所以我們的MockAPIService需要滿足下列兩個需求:

  1. 要能確定PhotoListViewModel是否真的呼叫了某隻function
  2. 要能夠指定不同的狀態,來模擬真實的server行為

先來看需求1.,針對需求1.,我們可以做出這樣的Mock:

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,我們就可以來寫我們的第一個測試:

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是這樣的:

  1. 網路層出錯的時候,要顯示錯誤訊息

根據這個case,我們可以寫出這樣的測試code:

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()

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會先啟動apiServicefetchPopularPhoto,並且設定好callback closure,等待apiService完成工作呼叫callback closure,再做對應的處理。所以我們如果在MockAPIService要模擬失敗的API request,就要從這個callback closure下手!

最後我們就實作出了這樣的MockAPIService

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.fetchSuccessMockAPIService.fetchFail被呼叫時,才會觸發callback。在我們還沒呼叫fetchSuccessfetchFail之前,PhotoListViewModel的callback是不會被呼叫的。這樣就完成了一個簡單的非同步、不同狀態的模擬。這樣的效果就如同上面的test code一樣,我們可以透過呼叫fetchFail並指定error object來模擬api request失敗的狀況。

Stubs for ViewModel

我們的ViewModel,除了提供各種properties讓View作資料的綁定之外,也提供接口讓View能夠把使用者的行為傳回來,並且做出相對應的改變,來讓View產生變化。這樣的行為,我們要怎樣做測試呢?一樣,我們先從use case開始看起:

  1. 當使用者點擊for sale的照片時要允許跳到下一頁

依照上面的use case,我們寫了以下的test code:

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:

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修改成這樣:

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裡面,接著觸發PhotoListViewModelinitFetch,這時候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:

  1. 要能啟動APIService上網抓資料
  2. 抓資料的時候要正確顯示讀取動畫
  3. 網路層出錯的時候,要顯示錯誤訊息
  4. cell數量要正確
  5. cell內容要正確
  6. 使用者點擊for sale的照片時要允許跳到下一頁
  7. 使用者點擊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

blade_runner.jpg

從很小的時候開始,這個畫面就是心中未來世界的代表,如果你問我未來世界長怎樣,我就會照著這個場景描述給你聽。到現在我還是能夠回想第一次看到這個場景設定的感動,也因為這部電影讓我開始喜歡cyberpuck,覺得那種用舊技術銓釋的未來十分迷人。

這部就是1982年的Blade Runner(銀翼殺手)

不過這部片在對話、敘事、還有邏輯的處理真的有待加強,bug超多,角色刻畫不深,還有很多如果沒有後人的解釋跟本連想像空間都沒有的晦澀對白,相較之下更單調的2001 Space Odessey反而在說故事方面略勝n籌XD

所以小蛇真正推薦的是Blade Runner 2049 XD

blade3.jpg

完全可以撐起原作甚至以說故事的能力來說還比前作強大,雖然以現代的眼光來看這種到處都是刻意雕琢的畫面及劇本,還有已經不算前衛影像風格可能已經不那麼吃香,但是因為它是Blade Runner的續作,所以看起來只有滿滿的感動 (已經無法中立評論XD) 總之請在看2049之前一定要看過1982或至少知道前作的故事,然後喜愛cyberpunk的記得去電影院看,雖然說現在只剩台北冷門時段有了XD
這部在IMDB拿到8.4高分的電影在票房上倒是有點悽慘,實在很可惜,科幻片在這個年代已經是復古的存在了XDDD

Show Comments

Get the latest posts delivered right to your inbox.