Compose 測試 v2 API

Compose 測試 API 的 v2 版本 (createComposeRulecreateAndroidComposeRulerunComposeUiTestrunAndroidComposeUiTest 等) 現已推出,可提升對協同程式執行的控制。這項更新不會複製整個 API 介面,只會更新用於建立測試環境的 API。

v1 API 已淘汰,強烈建議您遷移至新版 API。遷移作業會驗證測試是否符合標準協同程式行為,並避免日後發生相容性問題。如需已淘汰的 v1 API 清單,請參閱 API 對應表

這些變更已納入 androidx.compose.ui:ui-test-junit4:1.11.0-alpha03+androidx.compose.ui:ui-test:1.11.0-alpha03+

v1 API 依賴 UnconfinedTestDispatcher,而 v2 API 預設會使用 StandardTestDispatcher 執行組合。這項變更可讓 Compose 測試行為與標準 runTest API 保持一致,並明確控管協同程式的執行順序。

設定測試環境

Compose 測試 v2 API 會使用 ComposeUiTestConfig 自訂測試環境。用於建立測試設定函式的 API (例如 createComposeRulerunComposeUiTest 和其他相關 API) 會接受 ComposeUiTestConfig。這個設定物件會將環境相關的 API (例如 effectContextrunTestContexttestTimeout) 合併為單一物件。

設定模型也會管理 inputMode。Compose 測試 v2 API 預設會在每個測試開始時強制執行 InputMode.Touch,確保確定性,並防止輸入模式狀態在測試之間洩漏。

ComposeUiTestConfig 是 Compose Test v2 API 的一部分,預設會使用 StandardTestDispatcher。如果測試使用第 1 版 API,請先參閱「遷移至第 2 版測試 API」,再採用 ComposeUiTestConfig

遷移至 ComposeUiTestConfig

在測試中建立設定函式的超載中,接受個別設定參數 (例如 effectContextrunTestContexttestTimeout) 的多個超載已遭淘汰。請更新測試,改用 ComposeUiTestConfig,如下列範例所示:

預設輸入模式

如果測試依賴透過儀表 API 設定的非觸控輸入模式,遷移期間可能會失敗。在測試的設定函式中,系統預設會在每個測試開始時強制執行 InputMode.Touch,以提高確定性並防止狀態洩漏,同時覆寫周遭裝置狀態和測試前設定。

如要解決這個問題,請在 ComposeUiTestConfig 中指定必要的輸入模式:

class FocusTest {
    @get:Rule
    val rule = createComposeRule(
        config = ComposeUiTestConfig(inputMode = InputMode.Keyboard)
    )

    @Test
    fun testFocus() {}
}

如要為個別測試案例 (而非整個測試類別) 設定輸入模式,請將 ComposeUiTestConfig 傳遞至 runComposeUiTest

class FocusTest {
    @Test
    fun testTouchMode() = runComposeUiTest {
        // Runs with the default InputMode.Touch
    }

    @Test
    fun testKeyboardMode() = runComposeUiTest(
        ComposeUiTestConfig(inputMode = InputMode.Keyboard)
    ) {
        // Runs with InputMode.Keyboard
    }
}

如要瞭解其他遷移問題和解決方法,請參閱「未通過審查的常見原因和解決方法」。

遷移至 v2 測試 API

升級至 v2 API 時,您通常可以使用「尋找及取代」更新套件匯入項目,並採用新的調度器變更。

或者,您也可以使用下列提示,要求 Gemini 將 Compose 測試 API 遷移至第 2 版:

從 v1 測試 API 遷移至 v2 測試 API

系統會使用這項提示,引導您遷移至 v2 測試 API。

Migrate to Compose testing v2 APIs using the official
migration guide.

使用 AI 提示

AI 提示詞僅供在 Gemini 版 Android Studio 使用。

如要進一步瞭解 Android Studio 中的 Gemini,請前往:https://developer.android.com/studio/gemini/overview

請使用下表,將已淘汰的 v1 API 對應至 v2 替代項目:

已淘汰 (v1)

取代 (v2)

androidx.compose.ui.test.junit4.createComposeRule

androidx.compose.ui.test.junit4.v2.createComposeRule

androidx.compose.ui.test.junit4.createAndroidComposeRule

androidx.compose.ui.test.junit4.v2.createAndroidComposeRule

androidx.compose.ui.test.junit4.createEmptyComposeRule

androidx.compose.ui.test.junit4.v2.createEmptyComposeRule

androidx.compose.ui.test.junit4.AndroidComposeTestRule

androidx.compose.ui.test.junit4.v2.AndroidComposeTestRule

androidx.compose.ui.test.runComposeUiTest

androidx.compose.ui.test.v2.runComposeUiTest

androidx.compose.ui.test.runAndroidComposeUiTest

androidx.compose.ui.test.v2.runAndroidComposeUiTest

androidx.compose.ui.test.runEmptyComposeUiTest

androidx.compose.ui.test.v2.runEmptyComposeUiTest

androidx.compose.ui.test.AndroidComposeUiTestEnvironment

androidx.compose.ui.test.v2.AndroidComposeUiTestEnvironment

回溯相容性和例外狀況

現有的第 1 版 API 已淘汰,但請繼續使用 UnconfinedTestDispatcher 維持現有行為,並避免重大變更。

預設行為變更的唯一例外狀況如下:

用於在 AndroidComposeUiTestEnvironment 類別中執行組合的預設測試調度器,已從 UnconfinedTestDispatcher 切換為 StandardTestDispatcher。如果您使用建構函式建立例項,或是建立 AndroidComposeUiTestEnvironment 的子類別並呼叫該建構函式,就會受到影響。

主要異動:對協同程式執行的影響

API 第 1 版和第 2 版的主要差異在於協同程式的調度方式:

  • v1 API (UnconfinedTestDispatcher):啟動協同程式後,系統會立即在目前執行緒上執行,通常會在下一行測試程式碼執行前完成。與實際工作環境中的行為不同,這種立即執行可能會無意間遮蓋實際時間問題或競爭條件,而這些問題或條件會在實際運作的應用程式中發生。
  • v2 API (StandardTestDispatcher):啟動協同程式時,系統會將其排入佇列,並在測試明確推進虛擬時鐘前不會執行。標準 Compose 測試 API (例如 waitForIdle()) 已處理這項同步作業,因此大多數依賴這些標準 API 的測試應可繼續運作,不必進行任何變更。

未通過審查的常見原因和解決方法

升級至 v2 後,如果測試失敗,可能會有下列模式:

  • 失敗:您啟動工作 (例如 ViewModel 載入資料),但由於資料仍處於「載入中」狀態,因此斷言會立即失敗。
  • 原因:使用第 2 版 API 時,系統會將協同程式加入佇列,而非立即執行。工作已加入佇列,但檢查結果前從未實際執行。
  • 修正:明確推進時間。您必須明確告知 v2 派送器何時執行工作。

先前做法

在第 1 版中,工作會立即啟動並完成。在第 2 版中,下列程式碼會失敗,因為 loadData() 尚未實際執行。

// In v1, this launched and finished immediately.
viewModel.loadData()

// In v2, this fails because loadData() hasn't actually run yet!
assertEquals(Success, viewModel.state.value)

在進行判斷之前,請使用 waitForIdlerunOnIdle 執行佇列工作。

選項 1:使用 waitForIdle 將時鐘推進到 UI 閒置狀態,驗證協同程式是否已執行。

viewModel.loadData()

// Explicitly run all queued tasks
composeTestRule.waitForIdle()

assertEquals(Success, viewModel.state.value)

方法 2:使用 runOnIdle 會在 UI 閒置後,在 UI 執行緒上執行程式碼區塊。

viewModel.loadData()

// Run the assertion after the UI is idle
composeTestRule.runOnIdle {
    assertEquals(Success, viewModel.state.value)
}

手動同步處理

在手動同步處理的情況下 (例如停用自動前進),啟動協同程式不會立即執行,因為測試時鐘已暫停。如要在佇列中執行協同程式,但不推進虛擬時鐘,請使用 runCurrent() API。這會執行排定在目前虛擬時間執行的工作。

composeTestRule.mainClock.scheduler.runCurrent()

waitForIdle() 不同,runCurrent() 會在維持目前虛擬時間的同時,執行待處理的工作,而 則會推進測試時鐘,直到 UI 穩定為止。如果時鐘前進至閒置狀態,系統會略過中間狀態的驗證,但這項行為可讓您驗證這些狀態。

系統會公開測試環境中使用的基礎測試排程器。這個排程器可搭配 Kotlin runTest API 使用,同步處理測試時鐘。

遷移至 runComposeUiTest

如果您同時使用 Compose 測試 API 和 Kotlin runTest API,強烈建議改用 runComposeUiTest

先前做法

搭配使用 createComposeRulerunTest 會建立兩個不同的時鐘:一個用於 Compose,另一個用於測試協同程式範圍。這項設定可能會導致您必須手動同步處理測試排程器。

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testWithCoroutines() {
    composeTestRule.setContent {
        var status by remember { mutableStateOf("Loading...") }
        LaunchedEffect(Unit) {
            delay(1000)
            status = "Done!"
        }
        Text(text = status)
    }

    // NOT RECOMMENDED
    // Fails: runTest creates a new, separate scheduler.
    // Advancing time here does NOT advance the compose clock.
    // To fix this without migrating, you would need to share the scheduler
    // by passing 'composeTestRule.mainClock.scheduler' to runTest.
    runTest {
        composeTestRule.onNodeWithText("Loading...").assertIsDisplayed()
        advanceTimeBy(1000)
        composeTestRule.onNodeWithText("Done!").assertIsDisplayed()
    }
}

runComposeUiTest API 會在自己的 runTest 範圍內自動執行測試區塊。測試時鐘會與 Compose 環境同步,因此您不必再手動管理排程器。

    @Test
    fun testWithCoroutines() = runComposeUiTest {
        setContent {
            var status by remember { mutableStateOf("Loading...") }
            LaunchedEffect(Unit) {
                delay(1000)
                status = "Done!"
            }
            Text(text = status)
        }

        onNodeWithText("Loading...").assertIsDisplayed()
        mainClock.advanceTimeBy(1000 + 16 /* Frame buffer */)
        onNodeWithText("Done!").assertIsDisplayed()
    }
}