為什麼測試 Coroutine 需要特別的工具#

Coroutine 是非同步程式的解決方案。當我們把耗時任務放進 suspend 函式,正常情況下需要在 coroutine scope 中執行,例如使用 launchasync。在程式進入點,我們常常會使用 runBlocking 建立 coroutine scope,作為一般函式與 coroutine 世界的橋樑。

但當我們進入測試情境時,問題就來了:

  • suspend 函式必須在 coroutine 中執行。
  • 若直接在測試裡使用 runBlocking 包住 suspend 函式,遇到 delaywithTimeout 之類的時間相關 API,測試會真的等那麼久。
  • 一個有 30 秒延遲邏輯的測試,在 CI 上會真的耗掉 30 秒,這對快速回饋是致命的。

因此 Kotlin 官方提供了一個專門用來測試 coroutine 的函式庫 — kotlinx-coroutines-test。

kotlinx-coroutines-test 是什麼#

kotlinx-coroutines-test 是 JetBrains 為了測試 coroutine 行為所提供的函式庫,它的核心目標包含:

  • 提供能在 JUnit 測試中啟動 coroutine 的入口。
  • 控制虛擬時間(virtual time),讓 delay 不必真的等。
  • 控制排程行為,方便驗證 coroutine 的執行順序與時序。

加入相依性#

在 Gradle 專案中加入測試相依:

dependencies {
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2")
}

後續版本(1.6.0 以後)已經正式提供 runTestTestDispatcher 等新 API。本系列以 1.5.2 為例介紹核心概念,但實務上建議使用最新版本。

函式庫提供的主要元件#

kotlinx-coroutines-test 提供了幾組重要工具,後續章節會逐一介紹:

  • runBlockingTest(已棄用):早期版本最常見的測試入口。
  • runTest:新版取代 runBlockingTest 的測試入口,搭配 TestScope。
  • TestScope:在 runTest 中執行測試時所使用的 coroutine scope。
  • TestDispatcher:可控制虛擬時間的 dispatcher,包含 StandardTestDispatcher 與 UnconfinedTestDispatcher。
  • TestCoroutineScheduler:管理虛擬時間,可配合 advanceTimeBy、advanceUntilIdle、currentTime 等 API 操作。
  • Dispatchers.setMain / Dispatchers.resetMain:讓 Main 執行緒(Main Thread)能在 JVM 測試環境下被替換成 TestDispatcher。

一個簡單的範例#

我們有一個 Day27 類別,內部呼叫 download,並用 withTimeout 控制超時:

class Day27 {
    suspend fun download(service: IService): String {
        withTimeout(1000) {
            return@withTimeout service.download()
        }
        return "fail"
    }
}

interface IService {
    suspend fun download(): String
}

這裡刻意把 IService 抽成介面,方便測試時注入假實作。

對應的假服務:

class FakeService(private val timeout: Long) : IService {
    override suspend fun download(): String {
        delay(timeout)
        return "done"
    }
}

對應的測試(以舊版 runBlockingTest 為例):

internal class Day27Test {

    private lateinit var day27: Day27

    @BeforeEach
    internal fun setUp() {
        day27 = Day27()
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    internal fun downloadTest() = runBlockingTest {
        kotlin.test.assertEquals("done", day27.download(FakeService(100)))
    }
}

雖然 FakeService 內部有 delay,但因為跑在 runBlockingTest 中,測試會跳過真實時間直接前進,幾毫秒就跑完。

小結#

  • kotlinx-coroutines-test 是 Kotlin 官方提供的 coroutine 測試函式庫。
  • 它解決了「直接用 runBlocking 測試 suspend 函式會真的耗時」的痛點。
  • 核心概念是「虛擬時間」與「可控排程」。
  • 後續章節會分別介紹 runTest、TestDispatcher、以及 Main 執行緒測試的處理方式。

本系列原文撰寫時,許多 API 仍標記為 ExperimentalCoroutinesApi。新版多已穩定,但語意大致相同。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10276586