測試撰寫版面配置

測試 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 createComposeRule, but not createAndroidComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

這個模組包含 ComposeTestRule 和名為 AndroidComposeTestRule 的 Android 實作透過這項規則,您可以設定撰寫內容或存取活動。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 }

請注意,指定的條件只能查看受到這個時鐘影響的狀態 (這個狀態僅適用於「撰寫」狀態)。任何取決於 Android 措施或繪圖 (也就是在 Compose 外部測量或繪製) 的條件,都應使用較一般的概念,例如 waitUntil()

composeTestRule.waitUntil(timeoutMs) { condition }

常見模式

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

單獨測試

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

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

自訂語意屬性

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

// Creates a Semantics property of type boolean
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()

偵錯

如要在測試中解決問題,主要方法是查看語意樹狀結構。您可以在測試的任何時間點呼叫 findRoot().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()))
}

瞭解詳情

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