在 Jetpack Compose 中測試

1. 介紹與設定

在本程式碼研究室中,您將學習如何測試以 Jetpack Compose 建立的 UI。您將編寫第一批測試,同時瞭解獨立測試、偵錯測試、語意樹和同步處理。

軟硬體需求

查看本程式碼研究室的程式碼 (Rally)

您將以 Rally Material Design 研究做為此程式碼研究室的基礎。您可以在 android-compose-codelabs GitHub 存放區找到上述程式碼。如要複製,請執行:

git clone https://github.com/android/codelab-android-compose.git

下載完成後,開啟 TestingCodelab 專案。

或者,您也可以下載兩個 ZIP 檔案:

開啟 TestingCodelab 資料夾,其中包含名為 Rally 的應用程式。

檢查專案結構

Compose 測試是檢測設備測試。這代表必須搭配裝置 (實體裝置或模擬器) 執行。

Rally 內含若干檢測設備 UI 測試。您可以在 androidTest 來源集中找到這些測試:

b14721ae60ee9022.png

這是您用來放置新測試的目錄。歡迎查看 AnimatingCircleTests.kt 檔案,瞭解 Compose 測試的內容。

Rally 現已設定完成,不過,如要在新專案中啟用 Compose 測試,需要的只是相關模組 build.gradle 檔案中的測試依附元件而已:

androidTestImplementation "androidx.compose.ui:ui-test-junit4:$version"

debugImplementation "androidx.compose.ui:ui-test-manifest:$rootProject.composeVersion"

您可以執行並熟悉一下應用程式。

2. 要測試哪些項目?

我們要聚焦在 Rally 的分頁標籤列,當中包含一列分頁標籤 (「Overview」、「Accounts」和「Bills」),內容如下所示:

19c6a7eb9d732d37.gif

在本程式碼研究室中,您需要測試分頁標籤列的 UI。

這可能有很多涵義:

  • 測試分頁標籤是否顯示預期的圖示和文字
  • 測試動畫是否符合規格
  • 測試觸發的導覽事件是否正確
  • 測試不同狀態中 UI 元素的位置和距離
  • 擷取分頁標籤列的螢幕截圖,並與先前的螢幕截圖比較

確切的測試元件數量或測試規則不盡相同。您可以執行所有上述動作!在本程式碼研究室中,您將驗證以下項目,測試狀態邏輯是否正確:

  • 只有在選取分頁狀態下,才會顯示分頁標籤
  • 使用中的畫面會定義已選取的分頁

3. 建立簡單的 UI 測試

建立 TopAppBarTest 檔案

在與 AnimatingCircleTests.kt (app/src/androidTest/com/example/compose/rally) 相同的資料夾中建立新檔案,並命名為 TopAppBarTest.kt

Compose 隨附 ComposeTestRule,只要呼叫 createComposeRule() 即可取得。您可以利用這項規則設定受測試的 Compose 內容,並與之互動。

新增 ComposeTestRule

package com.example.compose.rally

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    // TODO: Add tests
}

單獨測試

在 Compose 測試中,我們可以透過類似在 Android View 中使用 Espresso 的方式,啟動應用程式的主要活動,方法是使用 createAndroidComposeRule

// Don't copy this over

@get:Rule
val composeTestRule = createAndroidComposeRule(RallyActivity::class.java)

不過,使用 Compose 就可以單獨測試元件,大幅簡化相關流程。您可以選擇要在測試中使用的 Compose UI 內容,方法是使用 ComposeTestRulesetContent 方法,而且可在任何位置呼叫 (但僅限一次)。

// Don't copy this over

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun myTest() {
        composeTestRule.setContent { 
            Text("You can set any Compose content!")
        }
    }
}

我們想要測試 TopAppBar,所以重點會放在此目的上。請呼叫 setContent 中的 RallyTopAppBar,然後讓 Android Studio 填寫參數名稱。

import androidx.compose.ui.test.junit4.createComposeRule
import com.example.compose.rally.ui.components.RallyTopAppBar
import org.junit.Rule
import org.junit.Test

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun rallyTopAppBarTest() {
        composeTestRule.setContent {
            RallyTopAppBar(
                allScreens = ,
                onTabSelected = { /*TODO*/ },
                currentScreen =
            )
        }
    }
}

能夠測試可組合項的重要性

RallyTopAppBar 採用三個很容易提供的參數,因此能傳遞我們控管的假資料。例如:

    @Test
    fun rallyTopAppBarTest() {
        val allScreens = RallyScreen.values().toList()
        composeTestRule.setContent { 
            RallyTopAppBar(
                allScreens = allScreens,
                onTabSelected = { },
                currentScreen = RallyScreen.Accounts
            )
        }
        Thread.sleep(5000)
    }

為讓您瞭解情況,我們也加入了 sleep()。請在 rallyTopAppBarTest 按一下滑鼠右鍵,然後點選「Run rallyTopAppBarTest()...」。

baca545ddc8c3fa9.png

這個測試連續 5 秒顯示頂端應用程式列,但套用了淺色主題,所以不是我們預期的結果!

原因在於,該應用程式列是透過 Material 元件建構而成,應位於 MaterialTheme 內,否則就會改回套用「基準」樣式顏色。

MaterialTheme 有完善的預設值,因此不會造成當機。由於我們不會測試主題或擷取螢幕截圖,因此可以省略這些,並使用預設的淺色主題。您可以將 RallyTopAppBarRallyTheme 包裝在一起來進行修正。

確認已選取該分頁標籤

請遵循以下模式,找出 UI 元素、檢查其屬性、並透過測試規則執行操作:

composeTestRule{.finder}{.assertion}{.action}

在本測試中,您會看到「Accounts」字詞,確認是否顯示所選分頁的標籤。

baca545ddc8c3fa9.png

建議您善用 Compose 測試一覽表測試套件參考說明文件,瞭解有哪些可用的工具。請使用適合這裡情況的尋找工具和斷言。例如:onNodeWithTextonNodeWithContentDescriptionisSelectedhasContentDescriptionassertIsSelected...

每個分頁標籤會有各自的內容說明:

  • 「Overview」(總覽)
  • 「Accounts」(帳戶)
  • 「Bills」(帳單)

知道這一點後,請將 Thread.sleep(5000) 替換成能夠尋找內容說明並斷言其存在的陳述式:

import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithContentDescription
...

@Test
fun rallyTopAppBarTest_currentTabSelected() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithContentDescription(RallyScreen.Accounts.name)
        .assertIsSelected()
}

現在再次執行測試,您應該會看到一個綠色測試:

75bab3b37e795b65.png

恭喜!您已編寫首個 Compose 測試,也學到了如何獨立執行測試並使用尋找工具和斷言。

這些內容很簡單,但需要事先瞭解元件 (內容說明和 selected 屬性) 的一些概念。在下個步驟中,您將學習如何檢查有哪些屬性可用。

4. 偵錯測試

在這個步驟中,您將確認是否以大寫字母顯示目前分頁的標籤。

baca545ddc8c3fa9.png

嘗試尋找文字並斷言其存在,是一種可能的解決方案:

import androidx.compose.ui.test.onNodeWithText
...

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithText(RallyScreen.Accounts.name.uppercase())
        .assertExists()
}

但如果您執行測試,測試會失敗😱

5755586203324389.png

在這個步驟中,您將瞭解如何使用語意樹偵錯。

語意樹

Compose 測試會使用稱為語意樹的結構,檢查畫面上的元素並讀取其屬性。這也是無障礙服務採用的結構,因為 TalkBack 等服務理應讀取這類服務。

您可以在節點上使用 printToLog 函式顯示語意樹。請在測試中加入新行:

import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
...

fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule.onRoot().printToLog("currentLabelExists")

    composeTestRule
        .onNodeWithText(RallyScreen.Accounts.name.uppercase())
        .assertExists() // Still fails
}

現在執行測試,查看 Android Studio 中的 Logcat (您可尋找 currentLabelExists)。

...com.example.compose.rally D/currentLabelExists: printToLog:
    Printing with useUnmergedTree = 'false'
    Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
     |-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
       [SelectableGroup]
       MergeDescendants = 'true'
        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
          Role = 'Tab'
          Selected = 'false'
          StateDescription = 'Not selected'
          ContentDescription = 'Bills'
          Actions = [OnClick]
          MergeDescendants = 'true'
          ClearAndSetSemantics = 'true'

在語意樹中,您會看到 SelectableGroup 有 3 個子元素,這些子元素代表頂端應用程式列的分頁標籤。但實際上,沒有任何 text 屬性具有值「ACCOUNTS」,這正是測試失敗的原因。不過,每個分頁標籤都有內容說明。您可以在 RallyTopAppBar.kt 內的 RallyTab 可組合函式中,查看這項屬性的設定方式:

private fun RallyTab(text: String...)
...
    Modifier
        .clearAndSetSemantics { contentDescription = text }

這個修飾符會清除子系中的屬性,並自行設定內容說明,因此您會看到「Accounts」,而不是「ACCOUNTS」。

請將尋找工具 onNodeWithText 替換成 onNodeWithContentDescription,然後重新執行測試:

fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithContentDescription(RallyScreen.Accounts.name)
        .assertExists()
}

b5a7ae9f8f0ed750.png

恭喜!您已修正測試,並已瞭解 ComposeTestRule、單獨測試、尋找工具、斷言和使用語意樹偵錯。

不過,也有個壞消息,那就是這項測試並不實用!仔細查看語意樹,您會發現無論是否選取分頁標籤,系統都會顯示這三個分頁標籤的內容說明。我們必須更深入思考!

5. 合併及未合併的語意樹

語意樹的特性是會盡可能精簡,僅顯示相關資訊。

例如 TopAppBar 中,圖示和標籤不必做為不同節點。請查看「Overview」節點:

120e5327856286cd.png

        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'

這個節點包含專為 selectable 元件定義的屬性 (例如 SelectedRole),以及整個分頁標籤的內容說明。這些是高階屬性,對簡易測試來說非常實用。圖示或文字的詳細資料可能重複,因此則不顯示。

Compose 會在部分可組合項 (例如 Text) 中自動顯示這些語意屬性。您也可以自訂及合併這些屬性,用來表示由一或多個子系組成的單一元件,例如包含 Text 可組合項的 Button。我們可透過屬性 MergeDescendants = 'true' 得知這個節點有子系,但已合併至該節點中。在測試中,通常需要存取所有節點。

為驗證是否顯示分頁標籤內的 Text,我們可以查詢將 useUnmergedTree = true 傳遞至 onRoot 尋找工具的未合併語意樹。

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

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

}

現在,Logcat 的輸出內容會稍長一些:

    Printing with useUnmergedTree = 'true'
    Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
     |-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
       [SelectableGroup]
       MergeDescendants = 'true'
        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |  |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
        |    Text = 'ACCOUNTS'
        |    Actions = [GetTextLayoutResult]
        |-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
          Role = 'Tab'
          Selected = 'false'
          StateDescription = 'Not selected'
          ContentDescription = 'Bills'
          Actions = [OnClick]
          MergeDescendants = 'true'
          ClearAndSetSemantics = 'true'

節點 #3 仍沒有子系:

        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'

但節點 6 (所選分頁標籤) 含有一個子系,而且現在我們可以看見「Text」屬性:

        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        |  |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
        |    Text = 'ACCOUNTS'
        |    Actions = [GetTextLayoutResult]

為驗證符合預期的正確行為,您要編寫比對工具,找出一個包含「ACCOUNTS」文字的節點,且其父項節點包含內容說明「Accounts」。

請再次查看 Compose 測試一覽表,試著找出編寫比對工具的方式。請注意,您可以使用布林運算子 (例如 andor) 搭配比對器。

所有尋找工具都有一個名為 useUnmergedTree 的參數。您只需將其設為 true,即可使用未合併的語意樹。

請試著不要參考解決方案,自行編寫測試!

解決方案

import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasText
...

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNode(
            hasText(RallyScreen.Accounts.name.uppercase()) and
            hasParent(
                hasContentDescription(RallyScreen.Accounts.name)
            ),
            useUnmergedTree = true
        )
        .assertExists()
}

繼續並執行測試:

94c57e2cfc12c10b.png

恭喜!在這個步驟中,您已瞭解如何合併屬性,並認識已合併和未合併的語意樹。

6. 同步處理

您撰寫的所有測試都必須與受測試的主體正確同步。舉例來說,當您使用 onNodeWithText 等尋找工具時,測試作業會等到應用程式處於「閒置」狀態後,才查詢語意樹。如果不同步,測試作業可能會在元素顯示之前尋找元素,或無謂地等待。

我們會使用這個步驟的「Overview」畫面,執行應用程式時的畫面會如下所示:

8c467af3570b8de6.gif

請留意「Alerts」資訊卡的重複閃爍動畫,這個元素能引人目光。

建立另一個名為 OverviewScreenTest 的測試類別,並新增下列內容:

package com.example.compose.rally

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.example.compose.rally.ui.overview.OverviewBody
import org.junit.Rule
import org.junit.Test

class OverviewScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun overviewScreen_alertsDisplayed() {
        composeTestRule.setContent {
            OverviewBody()
        }

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

如果您執行這項測試,會發現測試從未完成 (會在 30 秒後逾時)。

b2d71bd417326bd3.png

錯誤訊息顯示:

androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91 

這基本上表示 Compose 處於永久忙碌狀態,所以無法同步處理應用程式與測試。

您可能已經猜到,問題是會發生無限閃動動畫。應用程式一律不會處於閒置狀態,因此測試無法繼續進行。

以下說明無限動畫的實作方式:

app/src/main/java/com/example/compose/rally/ui/overview/OverviewBody.kt

var currentTargetElevation by remember {  mutableStateOf(1.dp) }
LaunchedEffect(Unit) {
    // Start the animation
    currentTargetElevation = 8.dp
}
val animatedElevation = animateDpAsState(
    targetValue = currentTargetElevation,
    animationSpec = tween(durationMillis = 500),
    finishedListener = {
        currentTargetElevation = if (currentTargetElevation > 4.dp) {
            1.dp
        } else {
            8.dp
        }
    }
)
Card(elevation = animatedElevation.value) { ... }

此程式碼基本上是等待動畫完成 (finishedListener),然後再次執行動畫。

修正這項測試的方法之一,就是停用開發人員選項中的動畫。在 View 環境中,這是廣為接受的其中一種方式。

在 Compose 中,動畫 API 的設計已考量可測試性,因此只要使用正確的 API,即可修正問題。我們可使用無限動畫,而不必重新啟動 animateDpAsState 動畫。

OverviewScreen 中的程式碼替換為適當的 API:

import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.Dp
...

    val infiniteElevationAnimation = rememberInfiniteTransition()
    val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
        initialValue = 1.dp,
        targetValue = 8.dp,
        typeConverter = Dp.VectorConverter,
        animationSpec = infiniteRepeatable(
            animation = tween(500),
            repeatMode = RepeatMode.Reverse
        )
    )
    Card(elevation = animatedElevation) {

如果您執行測試,現在測試能成功通過:

369e266eed40e4e4.png

恭喜!在這個步驟中,您已瞭解同步處理作業,以及動畫對測試的影響。

7. 自選練習

在這個步驟中,您將採用一項操作 (請參閱測試一覽表),證明點選 RallyTopAppBar 中的不同分頁標籤,確實會改變選取項目。

提示:

  • 測試範圍需包含 RallyApp 所擁有的狀態。
  • 請驗證狀態,而非行為。使用 UI 狀態上的斷言,而不是依賴已呼叫的物件和方式。

這項練習未提供解決方案。

8. 後續步驟

恭喜!您已完成 Jetpack Compose 測試。現在您已具備基本知識,可以為 Compose UI 建立良好的測試策略。

如想進一步瞭解測試與 Compose,請參閱下列資源:

  1. 測試說明文件進一步說明了尋找工具、斷言、動作和比對工具的資訊,並介紹同步處理機制和時間控管功能等。
  2. 測試一覽表加入書籤!
  3. Rally 範例提供簡易的螢幕截圖測試類別。請查看 AnimatingCircleTests.kt 檔案瞭解詳情。
  4. 若需關於測試 Android 應用程式的一般指南,請參考下列三個程式碼研究室:
  1. GitHub 上的 Compose 範例存放區有多個具備 UI 測試的應用程式,歡迎參考。
  2. Jetpack Compose Pathway 列有可協助您開始使用 Compose 的資源清單。

預祝測試愉快!