測試撰寫版面配置

測試 UI 或螢幕會用來驗證撰寫程式碼的正確行為,可在開發程序早期偵測錯誤,改善應用程式的品質。

Compose 提供一組測試 API 來找出元素、驗證其屬性,以及執行使用者動作。以及時間管理等進階功能。

語義學

Compose 中的 UI 測試使用語意與 UI 階層互動。 「語意」一詞的意思是指定 UI 的意義。在這種情況下,「使用者介面 (或元素)」是指單一組合和全螢幕的任何元素。語意樹狀目錄會隨 UI 階層一起產生並加以描述,

這張圖表顯示了一般 UI 版面配置,以及該版面配置對應到對應的語意樹狀結構

圖 1。一般 UI 階層及其語意樹狀結構。

語意架構主要用於無障礙功能,因此測試能利用語意相關 UI 階層所揭露的資訊。開發人員可自行決定要公開的內容及程度。

含有圖片和文字的按鈕

圖 2。包含圖示和文字的一般按鈕。

舉例來說,假設有個按鈕像上圖一樣有圖示和文字元素,預設的語意樹狀結構中只會包含「喜歡」文字標籤。這是因為某些可編譯的物件 (例如 Text) 已經對語意樹狀結構公開部分屬性。您可以使用 Modifier 將屬性新增至語意樹狀結構。

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

設定

本節說明如何設定模組,讓您測試撰寫程式碼。

首先,請將下列依附元件新增至包含 UI 測試的模組的 build.gradle 檔案:

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createAndroidComposeRule, but not createComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

這個模組包含 ComposeTestRule,以及名為 AndroidComposeTestRule 的 Android 實作項目。透過這項規則,您可以設定 Compose 內容或存取活動。這些規則是使用工廠函式 createComposeRule 建構;如果需要存取活動,則使用 createAndroidComposeRule 建構規則。Compose 的一般 UI 測試如下所示:

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule val composeTestRule = createComposeRule()
    // use createAndroidComposeRule<YourActivity>() if you need access to
    // an activity

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

測試 API

與元素互動的方法主要有三種:

  • 「Finder」可讓您選取一或多個元素 (或 Semantics 樹狀結構中的「節點」) 來進行宣告或執行操作。
  • 宣告是用來驗證元素是否存在或具有特定屬性。
  • 動作會在元素上插入模擬使用者事件,例如點擊或其他手勢。

其中部分 API 接受 SemanticsMatcher 來參照語意樹狀結構中的一或多個節點

搜尋器

您可以分別使用 onNodeonAllNodes 來選取一個或多個節點,亦可以使用方便的搜尋工具進行一般的搜尋,例如 onNodeWithTextonNodeWithContentDescription 等。請前往 Compose 測試一覽表以瀏覽完整清單。

請選取單一節點

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

選取多個節點

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

使用未合併樹狀結構

部分節點會合併子項的語意資訊。舉例來說,含有兩個文字元素的按鈕會合併標籤:

MyButton {
    Text("Hello")
    Text("World")
}

透過測試,我們可以使用 printToLog() 顯示語意樹狀結構:

composeTestRule.onRoot().printToLog("TAG")

這個程式碼會輸出下列輸出內容:

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

如果您需要比對未合併樹狀結構的節點,可以將 useUnmergedTree 設為 true

composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")

這個程式碼會輸出下列輸出內容:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = '[Hello]'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = '[World]'

useUnmergedTree 參數適用於所有尋找工具。例如,這在 onNodeWithText 搜尋器中使用。

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

斷言

透過搜尋工具傳回一或多個配對器,然後在 SemanticsNodeInteraction 上呼叫 assert() 以確認宣告:

// Single matcher:
composeTestRule
    .onNode(matcher)
    .assert(hasText("Button")) // hasText is a SemanticsMatcher

// Multiple matchers can use and / or
composeTestRule
    .onNode(matcher).assert(hasText("Button") or hasText("Button2"))

您也可以使用便利的函式處理最常見的斷言,例如 assertExistsassertIsDisplayedassertTextEquals 等。請前往 Compose 測試一覽表以瀏覽完整清單。

你也可以使用函式來檢查對一組節點的宣告:

// Check number of matched nodes
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())

動作

如要在節點中插入動作,請呼叫 perform…() 函式:

composeTestRule.onNode(...).performClick()

以下列舉幾個動作:

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

您可以在 Compose Testing 一覽表中瀏覽完整清單。

比對器

本節說明可用於測試撰寫程式碼的部分比對器。

階層比對器

階層比對器可讓您調整語意樹狀結構,並執行簡單的比對作業。

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

以下為這些比對器的範例:

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

選擇器

另一個建立測試的方法是使用「選取器」,讓部分測試更容易理解。

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

您可以在 Compose Testing 一覽表中瀏覽完整清單。

同步處理

根據預設,系統會與您的使用者介面同步處理撰寫測試。透過 ComposeTestRule 呼叫宣告或動作時,系統會預先同步處理測試,直到 UI 樹狀結構閒置為止。

一般來說,您不必採取任何行動。但仍有一些邊緣案例需要知道。

測試進行同步處理後,Compose 應用程式就會使用虛擬時鐘適時完成進階。這表示 Compose 測試不會即時執行,因此可以盡可能快速通過。

不過,如果您沒有使用同步處理測試的方法,則不會發生重覆現象,且 UI 會暫停。

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions
    var lastSeenValue = 0 // Used to track recompositions
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition

    // Fails because nothing triggered a recomposition
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition
    composeTestRule.onNodeWithText("1").assertExists()
}

請注意,這項規定僅適用於 Compose 階層,應用程式的其他部分並不適用。

停用自動同步功能

透過 ComposeTestRule (例如 assertExists()) 呼叫宣告或動作時,您的測試會與 Compose UI 同步處理。在某些情況下,您可能會想要停止同步處理並自行控管時鐘。舉例來說,您可以控制在 UI 忙碌期間,為動畫擷取準確螢幕截圖的時間。如要停用自動同步功能,請將 mainClock 中的 autoAdvance 屬性設為 false

composeTestRule.mainClock.autoAdvance = false

一般而言,您需要自行安排時間。您可以透過 advanceTimeByFrame() 將特定影格前進,也可以使用 advanceTimeBy() 按照特定時間長度快一點:

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

閒置資源

Compose 可以同步處理測試和 UI,讓所有動作和宣告都以閒置狀態完成,然後視需要等待或往前調整時鐘。但是,某些影響 UI 狀態的非同步作業可能會在背景執行,但測試無法得知這些結果。

您可以在測試中建立及註冊這些閒置資源,以便判斷要測試的應用程式是否忙碌中或閒置。除非您需要註冊額外的 ID 資源,否則不需要採取行動。舉例來說,如果您執行的背景工作並未與 Espresso 或 Compose 同步處理。

這個 API 與 Espresso 的 Idling Resources 非常類似,可讓您表示接受測試的實體是閒置或忙碌。您可以使用「撰寫」測試規則來註冊 IdlingResource 的實作。

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

手動同步處理

在某些情況下,您必須將 Compose UI 與其他測試或您要測試的應用程式同步處理。

waitForIdle 會等待撰寫作業閒置,但取決於 autoAdvance 屬性:

composeTestRule.mainClock.autoAdvance = true // default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for Idling Resources to become idle

請注意,在這兩種情況下,waitForIdle 也會等候待處理的提取和版面配置傳遞

此外,您也可以將時鐘加至 advanceTimeUntil() 符合特定條件為止。

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

請注意,特定條件應檢查可能受到這個時鐘影響的狀態 (僅適用於 Compose 狀態)。

正在等待條件

任何依附於外部工作的條件,例如資料載入或 Android 測量或繪圖 (也就是在 Compose 外部測量或繪製),則應使用較一般的概念,例如 waitUntil()

composeTestRule.waitUntil(timeoutMs) { condition }

您也可以使用任何 waitUntil 輔助工具

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

常見模式

本節將說明 Compose 測試中常用的幾種做法。

獨立測試

ComposeTestRule 可讓您啟動顯示任何可組合項的活動:完整應用程式、單一畫面或小型元素。同時也建議您檢查可組合的元件是否封裝正確,且可單獨進行,以便針對更焦點且更易於執行的 UI 測試。

這不代表只能建立單元 UI 測試。對 UI 中範圍較大的部分進行的測試也同樣重要。

設定自己的內容後存取活動和資源

您通常需要使用 composeTestRule.setContent 設定要測試的內容,也需要存取活動資源,例如聲明宣告的文字與字串資源相符。不過,如果活動已經呼叫 setContent,您無法再為使用 createAndroidComposeRule() 建立的規則呼叫它。

為此,常見的模式是使用空白活動 (例如 ComponentActivity) 建立 AndroidComposeTestRule

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, /*...*/)
            }
        }
        val continueLabel = composeTestRule.activity.getString(R.string.next)
        composeTestRule.onNodeWithText(continueLabel).performClick()
    }
}

請注意,您必須在應用程式的 AndroidManifest.xml 檔案中新增 ComponentActivity。方法很簡單,只要將這個依附元件新增至模組即可:

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

自訂語意屬性

可以建立自訂語意屬性,向測試對象顯示資訊。做法是定義新的 SemanticsPropertyKey,並透過 SemanticsPropertyReceiver 提供。

// Creates a Semantics property of type Long
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

您現在可以使用 semantics 調節係數使用該屬性:

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

透過測試,您可以使用 SemanticsMatcher.expectValue 聲明屬性的值:

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

驗證狀態還原

重新建立活動或程序後,請確認 Compose 元素的狀態是否已順利還原。您可以直接使用 StateRestorationTester 類別執行這類檢查,而不必依賴重新建立活動。

這個類別可讓您模擬可組合項的重新建立。請務必驗證 rememberSaveable 的實作方式。


class MyStateRestorationTests {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)

        restorationTester.setContent { MainScreen() }

        // TODO: Run actions that modify the state

        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()

        // TODO: Verify that state has been correctly restored.
    }
}

測試不同的裝置設定

Android 應用程式必須適應許多不斷變化的條件,例如視窗大小、語言代碼、字型大小、深色和淺色主題等等。這些條件大多衍生自使用者控制的裝置層級值,並以目前的 Configuration 執行個體公開。由於測試必須設定裝置層級屬性,因此直接在測試中測試不同設定並不容易。

DeviceConfigurationOverride 是僅供測試的 API,可讓您針對測試中的 @Composable 內容,以本地化方式模擬不同的裝置設定。

DeviceConfigurationOverride 的隨附物件包含下列擴充功能函式,可覆寫裝置層級設定屬性:

如要套用特定覆寫值,請將受測試的內容納入 DeviceConfigurationOverride() 頂層函式的呼叫中,然後將要套用的覆寫值做為參數傳遞。

舉例來說,以下程式碼會套用 DeviceConfigurationOverride.ForcedSize() 覆寫以在本機變更密度,強制 MyScreen 可組合項在大型橫向視窗中轉譯,即使執行測試的裝置不支援直接使用該視窗大小也一樣:

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.ForcedSize(DpSize(1280.dp, 800.dp))
    ) {
        MyScreen() // will be rendered in the space for 1280dp by 800dp without clipping
    }
}

如要一次套用多個覆寫值,請使用 DeviceConfigurationOverride.then()

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.FontScale(1.5f) then
            DeviceConfigurationOverride.FontWeightAdjustment(200)
    ) {
        Text(text = "text with increased scale and weight")
    }
}

偵錯

如要在測試中解決問題,主要方法是查看語意樹狀結構。您可以在測試的任何時間點呼叫 composeTestRule.onRoot().printToLog() 來列印樹狀結構。這個函式會印出類似以下的記錄:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hi'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'There'

這些記錄含有重要資訊,可用於追蹤錯誤。

與 Espresso 的互通性

在混合式應用程式中,您可以在檢視表階層中找到 Compose 元件,而在 Compose 元件組合 (透過 AndroidView 元件) 中找到檢視畫面。

這兩種類型不需要任何特殊步驟。您可以透過 Espresso 的 onView 比對檢視畫面,並透過 ComposeTestRule 比對 Compose 元素。

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

與 UiAutomator 的互通性

可組合項預設只能用其便利描述項 (顯示文字、內容說明等等) 才能從 UiAutomator 存取。如需存取任何使用 Modifier.testTag 的可組合項,則需為特定的可組合項子樹狀結構啟用語意屬性 testTagsAsResourceId。如果可組合項並無其他任何專屬控制代碼,例如捲動式可組合項 (例如:LazyColumn),啟用這項行為會非常實用。

您只能在可組合項階層達到高時才能啟用此行為,以便確保所有設有 Modifier.testTag 的巢狀結構可組合項都可以從 UiAutomator 存取。

Scaffold(
    // Enables for all composables in the hierarchy.
    modifier = Modifier.semantics {
        testTagsAsResourceId = true
    }
){
    // Modifier.testTag is accessible from UiAutomator for composables nested here.
    LazyColumn(
        modifier = Modifier.testTag("myLazyColumn")
    ){
        // content
    }
}

任何設有 Modifier.testTag(tag) 的可組合項都可以藉由使用 By.res(resourceName) 並利用相同的 tag 做為 resourceName 的方式存取。

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

瞭解詳情

詳情請參閱 Jetpack Compose Testing 程式碼研究室

範例