Android 寫測試系列 (3) — 對擁有 CoroutineScope 物件寫測試
上一篇介紹了 BDD 以及如何對一般物件寫測試,讓我們可以開始為了提高程式穩定性做準備。而上一篇的情境較為簡單,用了 suspend function 可以解決 call 單支 api 的需求,但有時候我們需要同時處理多個異步需求,這時候使用 withContext()
會不能同時進行多個異步工作,我們會需要一個 CoroutineScope
同時 launch
多個 job 來提高效率,有些物件會因為架構設計而擁有自己的 CoroutineScope,或是像 ViewModel
本身就有 CoroutineScope ,這些擁有 CoroutineScope 的物件在測試上因為執行異步工作時而可能有以下問題發生:
- 究竟是測試先跑完還是異步工作先處理完? 🤔
- 我明明就 mock 異步工作中的外部物件,也有設定了該工作的回傳值,為什麼測試執行後偶爾會莫名報錯? 😢
- 我 local 就跑得過,為什麼 CI 就報錯? 😠
如果你遇到了以上幾點,也許這篇文章能夠幫你找到個方向,這些原因沒意外都是因為 race condition 而引起的,要解決這個問題還得先了解原理。
Coroutine 原理簡介
在介紹之前,請先看看這段程式碼
看完之後你覺得 doSomething()
的執行順序會是怎麼樣?那麼來公布答案
執行結果是
在 doSomething()
執行完後才去執行 launch
的內容,launch 的內容又包含執行緒的切換,所以我們在跑測試時可能會發生測試在檢驗結果時,檢驗結果不一定已執行完畢而導致測試可能失敗的問題。
既然看到了上面的答案,那我們再玩一次,請猜猜下面程式碼的執行結果
以上程式碼基本上只將 coroutineScope
換成 ViewModel 提供的 viewModelScope
而已,執行順序不會因為從一般物件變成 ViewModel 而有所改變,那執行結果是怎麼樣呢?
也許這時候你在想
只是將實體為 MainScope
的 CoroutineScope 換成 viewModelScope
後有這樣的差距究竟是為什麼呢?
那讓我們來看看 MainScope
和 viewModelScope
的實作
MainScope 使用的是 Dispatchers.Main
,而 viewModelScope 使用的是 Dispatchers.Main.immediate
,這 2 者的差異在
- Dispatchers.Main 使用
Handler.post()
來處理 launch 的 job,所以不會立即處理 job 中的內容 - Dispatchers.Main.immediate 會判斷當前的 Thread 是不是 Main Thread,如果是的話就立即執行,不是的話就一樣用
Handler.post()
來分發 job 給 Main Thread
知道了這 2 個的差異,也許你會想
這樣以後我是不是都要用
Dispatchers.Main.immediate
來開發
以我的開發經驗來說,在開發時這 2 種 Dispatcher 用起來其實差不多,當有需要自己給一個 CoroutineScope 且預設要在 Main Thread 時直接用 MainScope()
就好,如果擔心剛剛提到跑測試的順序問題可能影響測試結果的話,Coroutine 提供了測試工具 TestCoroutineDispatcher()
來避免這問題,晚點我們再來詳細介紹它。
這時候我們還有一個問題要解決,就是那個 withContext(Dispatchers.IO)
,我們要怎麼確保在檢驗結果時 background thread 已經執行完畢,那我們在檢驗前加個 delay
如何?於是我們寫出下面這個測試
只要我 delay 的夠久,任何異步工作都能夠執行完
如果我們在 background thread 執行的內容只有 call api,在寫測試時因為 mock 掉 call api 這段,所以不會花太久時間。如果 background thread 執行的是長時間的運算,像是 Dcard 文章的留言可能只有 1 則,也有多達 30 萬則,產生留言的程式執行時間會從 1ms ~ 1500ms,在這個情況下 delay 設定太短會導致測試來不及執行完,設定太長也只是白白佔據電腦資源。
另外測試結果的成功與否不該與執行環境有關,硬體規格的不同會使程式執行時間不同,設定固定的 delay 可能會造成不同結果,我們測試的是商業邏輯,在 10 核心的高級電腦測試成功的話,就不能在雙核心的文書機測試失敗,所以 delay 這招只能最後在用上,於是我尋求別的方式
那等它做完呢?
上面是我當初的第一個想法,如果我不知道它要做多久,那就等它做完吧,畢竟 Coroutine 提供許多優異的 api,一定有我可以用的,於是我將問題拆解成 2 個部分
- 等待開啟的 job 做完,job 開啟的多個子 job 也要等
- 一個 function 可能開了多個 job,所以新增所有的 job 都要做完
於是就來解題了
等待開啟的 job 做完,job 開啟的多個子 job 也要等
我想到 Job 的 join()
function,在官方範例的 Parental responsibilities(跳轉似乎會失敗,要往下滑一些) 章節中,有這個範例:
執行結果為:
request: I’m done and I don’t explicitly join my children that are still active
Coroutine 0 is done
Coroutine 1 is done
Coroutine 2 is done
Now processing of the request is complete
由上面這個例子,我們可以知道對一個 job join()
後,會等到其所有子 job 結束才算完成,這題就輕鬆解掉了。
一個 function 可能開了多個 job,所以這次新增的 job 都要做完
這是個有趣的題目,我們要怎麼知道一個 function 執行後多了幾個 job,在研究 Coroutine 的 source code 後有了一些眉目 😎,把相關 code 整理出來後,我們先產生一個 CoroutineScope 後來看它怎麼運作
在第 4 行看到會檢查 CoroutineContext
有沒有包含 Job
,沒有的話就建立一個 job 物件,從第 7 行的 coroutineContext[Job]
也可以看到是從 Job
取出這個 CoroutineScope 的 root job,那麼 Job
是什麼東西,可以將 CoroutineContext 當成是個 Map<Key, Element>
的物件,coroutineContext[Job]
用的 Job
實際上是 companion object Key
,我們可以用另外一個例子來了解
companion object 沒取名時預設的名字叫 Companion
,如果我們要使用 companion object 可以直接用 Class 名稱 Layout
或是 Class 的 companion object 的名字 Layout.Companion
,回到 Job
的寫法,其實 coroutineContext[Job]
就等於 coroutineContext[Job.Key]
,透過 Job.Key
我們就可以拿到 root job,到了這邊我們就完成一半。
接下來我們只要找到一段程式的執行後新增的 job 並且對它們 join()
後,我們就可以等待異步程式執行完,於是我寫了一個 extension function,這在待會撰寫的測試就會使用到
TestCoroutineDispatcher 介紹
剛剛有提到 Coroutine 官方有提供這個東西讓我們使用,這東西主要有這些功能
- Dispatcher 的 job 會立刻執行
- Dispatcher 的 delay 會立刻結束
所以我們可以拿來取代原本的 Dispatchers.Main
讓 Dispatchers.Main
和 Dispatchers.Main.immediate
針對 launch job 的執行流程相同,只要在 ProjectConfig
的 beforeAll()
和 afterAll()
這樣設定就可以
測試撰寫
我們將上一次的 StickerHandler 稍微改寫一下,拿掉 suspend
並讓 Handler 持有自己的 CoroutineScope,這是改寫後的結果
然後我們就可以利用這次新加的 extension 去改寫上次的測試
基本上我們只要把原本的 stickerHandler.fetchSticker()
用 stickerHandler.coroutineScope.waitForJobsToFinish
包起來就好,至於其他沒有用到 coroutineScope
的 function 要不要包我覺得都可以,包起來是看起來有一致性,但是其實運作時跟 coroutineScope
沒關係,我自己平常是沒包,這邊就包起來給大家參考。
上面介紹了使用 join()
來等待異步工作結束的方式,那有沒有什麼情況是無法用等待的方式?
有!如果工作是不會結束的,那麼也會等到天荒地老,這邊就介紹一個例子
在這個例子中,如果 observeRoomData()
提供的是不會關閉的 Flow,那 launch 出來的 Job 是不會結束的,所以在這個測試中測試會跑到 timeout
一般常見的 Flow 都是不會關閉的,所以我們可以想想哪些是會讓 Flow 關閉
- emptyFlow
- .take(n)
這 2 個是我常利用的方式讓 Flow 關閉,使用的情境我會分成
- Flow 的
collect
中執行的內容不重要,我只需要驗證 function 有沒有被呼叫 - Flow 的
collect
中執行的內容是測試的目標,需要測試執行的結果
依照上面的方向,可以將上面的測試分成 2 個情境,並改寫成這樣
在實作驗證結果的方式有很多種,可以依需求用不同方式實作。不過有一點比較重要的是,有用到不會關閉的實作要記得在測試結束時 cancel
CoroutineScope 避免可能有工作沒有結束的情況產生。理論上有用到 CoroutineScope 都要 cancel 掉,不過在一般情況測試跑完時異步程式也跑完了所以影響不大,在使用 Flow 時不同的實作可能不會關閉對 Flow 的觀察,不關閉會讓資源沒辦法正確釋放,所以如果都能 cancel
的話是最好的。
這篇介紹了如何針對擁有 CoroutineScope 的物件做測試,並寫了 extension 幫助我們能針對測試做一些最佳化,避免讓資源無謂的浪費。這篇的測試對象也滿廣泛的,像是 ViewModel 和 Singleton 物件都能夠適用,希望這篇能降低對 Coroutine 的測試門檻。
下一篇文章會介紹針對 Singleton 物件寫測試有什麼樣的重點,並且搭配 DI Framework Koin 注入 Singleton 需要的物件,就可以說明漏掉了其中的一步測試可能就會錯誤。
最後幫大家整理 3 個重點
參考資料: