Android 寫測試系列 (3) — 對擁有 CoroutineScope 物件寫測試

黃德銘
Dcard Tech Blog
Published in
10 min readAug 12, 2022

上一篇介紹了 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 後有這樣的差距究竟是為什麼呢?

那讓我們來看看 MainScopeviewModelScope 的實作

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.MainDispatchers.MainDispatchers.Main.immediate 針對 launch job 的執行流程相同,只要在 ProjectConfigbeforeAll()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 個重點

參考資料:

--

--

黃德銘
Dcard Tech Blog

愛東玩西玩的 Android 工程師,玩過 Ktor, Jetpack Compose for Desktop 和 iOS