在 Jetpack Compose 中測試

1. 介紹與設定

在本程式碼研究室中,您將瞭解測試使用 Jetpack Compose 建立的使用者介面。您將撰寫第一批測試,同時瞭解獨立測試、偵錯測試、語意樹狀圖和同步處理。

軟硬體需求

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

您將使用 Rally 質感設計研究作為此程式碼研究室的基礎。您可以在 android-compose-codelabs GitHub 存放區找到所需的程式碼。若要執行複製作業,請執行:

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

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

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

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

檢查專案結構

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

Rally 包含一些檢測設備使用者介面測試。您可以在 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

在本程式碼研究室中,您需要測試分頁標籤列的使用者介面。

這可能有很多涵義:

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

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

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

3. 建立簡單的使用者介面測試

建立 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 秒),並不是我們預期的結果!

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

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

確認已選取該分頁標籤

遵循此模型,找到測試使用者介面元素、檢查其屬性、和透過測試規則執行動作:

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 函式列印 Semantics 樹狀圖。在測試中加入新行:

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.toUpperCase())
        .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'

在 Semantics 樹狀圖中,您會看到 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、單獨測試、尋找工具、斷言和使用 Semantics 樹狀圖偵錯。

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

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) 中自動顯示這些 Semantics 屬性。您也可以自訂及合併來代表由一或多個子系組成的單一元件,舉例來說,可以代表包含 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

恭喜!在這個步驟中,您已瞭解屬性 merging (合併),以及合併和未合併的語意樹狀圖。

6. 同步

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

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

8c467af3570b8de6.gif

請注意,重複「快訊」資訊卡的閃動動畫,必須特別注意這個元素。

建立另一個名為 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 中,Animation 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 所擁有的狀態。
  • 驗證狀態,而非行為。使用在使用者介面狀態上的斷言,而不是依賴已呼叫的物件和方式。

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

8. 後續步驟

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

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

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

預祝測試愉快!