Android Unit Test單元測試

單元測試 (Unit Testing) 是針對應用程式中最小可測試單元(通常是函式或類別)進行的驗證。在 Android 中,穩定的單元測試能大幅縮短開發週期,並確保核心邏輯在重構過程中不被破壞。

測試結構:AAA 模式

一個編排良好的測試案例應遵循 AAA 模式,這能讓測試代碼極具可讀性:

  1. Arrange (準備):初始化物件、Mock 依賴項、準備輸入數據。
  2. Act (執行):呼叫被測試的方法。
  3. Assert (斷言):驗證結果是否符合預期。
@Test
fun `測試加法器應回傳正確總和`() {
    // Arrange
    val calculator = Calculator()
    
    // Act
    val result = calculator.add(2, 3)
    
    // Assert (使用 Google Truth 斷言庫)
    assertThat(result).isEqualTo(5)
}

使用反引號 (Backtick) 的特殊用途

在 Kotlin 中,被反引號 (`) 包圍的字串可以作為合法的標識符(如函式名、變數名)。這在單元測試中有兩個非常實用的用途:

1. 編寫具備描述性的測試名稱

在 Java 或其他語言中,你可能需要使用駝峰式 (testUserLoginSuccess) 或底線式 (test_user_login_success) 來命名測試,但這往往不夠直觀。Kotlin 允許你在測試名稱中使用空格:

@Test
fun `當使用者輸入正確密碼時,應成功登入並跳轉首頁`() {
    // 這種寫法讓測試報告看起來像是一份規格文件
}

2. 解決關鍵字衝突 (Keyword Escaping)

當你需要呼叫 Java 程式碼中與 Kotlin 關鍵字重名的方法時,反引號是唯一的解決方案。例如,Java 類別中有一個方法叫做 is()

// 無法直接呼叫,因為 is 是 Kotlin 的關鍵字
// mock.`is`() ✅ 使用反引號逃逸關鍵字

進階 Mock 技巧 (Mockk)

當測試對象(如 ViewModel)依賴於外部資源(如 Repository)時,我們使用 Mockk 來模擬這些行為。

基本 Stubbing 與 coEvery

對於解除協程掛起函式的 Mock,需使用 coEvery

coEvery { repository.getUsers() } returns listOf(User("Mike"))

使用 Slots 捕獲參數

當你想驗證傳入 Mock 物件的參數內容時,可以使用 slot

val slot = slot<User>()
coEvery { repository.saveUser(capture(slot)) } just runs

viewModel.register("Mike")

// 驗證傳給 repository 的 User 物件名稱是否正確
assertThat(slot.captured.name).isEqualTo("Mike")

Answers:動態回傳結果

如果回傳值需要根據傳入參數變動,可以使用 answers

every { api.fetch(any()) } answers {
    val id = firstArg<String>()
    if (id == "admin") "Admin Data" else "User Data"
}

協程測試 (Testing Coroutines)

要在單元測試中處理協程,必須解決 Main 執行緒在 JVM 環境下缺失的問題。

MainDispatcherRule

建立一個 JUnit Rule 來自動切換測試用的 Dispatcher。

class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

使用 runTest

runTest 能跳過协程中的延遲(如 delay()),讓測試瞬間完成。

@get:Rule val mainRule = MainDispatcherRule()

@Test
fun `測試背景同步邏輯`() = runTest {
    viewModel.startSync()
    advanceUntilIdle() // 強制執行完所有掛起任務
    assertThat(viewModel.isComplete.value).isTrue()
}

Flow 測試與 Turbine

測試 Kotlin Flow 時,建議使用 Turbine 函式庫,它能讓你像處理列表一樣預期 Flow 發出的值。

@Test
fun `測試狀態流變動`() = runTest {
    viewModel.uiState.test {
        // 初始狀態應為 Loading
        assertThat(awaitItem()).isInstanceOf(UiState.Loading::class.java)
        
        viewModel.loadData()
        
        // 隨後應發出 Success
        assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)
        
        // 確保沒有更多事件
        cancelAndIgnoreRemainingEvents()
    }
}

狀態驗證 vs 互動驗證

  • 狀態驗證 (State Testing):檢查物件的屬性或回傳值(如驗證 uiState)。這是最推薦的做法,因為它不依賴實作細節。
  • 互動驗證 (Interaction Testing):驗證某個函式是否有被呼叫(使用 verify)。
// 互動驗證:確保 repository.getUsers() 真標被呼叫過一次
coVerify(exactly = 1) { repository.getUsers() }

準則: 優先驗證「結果」(狀態),只有在沒有狀態可查(如呼叫分析工具上報)時,才驗證「互動」。

透過系統化的單元測試,你的 Android 程式碼將變得更加強健且易於長期維護。