在 Jetpack Compose 中測試

1. 介紹與設定

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

軟硬體需求

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

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

$ git clone https://github.com/googlecodelabs/android-compose-codelabs.git

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

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

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

檢查專案結構

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

Rally 包含一些檢測設備使用者介面測試。您可以在 androidTest 來源集中找到:

6f0f6fd5cd023084.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 使用者介面內容。方法是使用 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 包裝在一起來進行修正。

確認已選取該分頁標籤

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

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

在這個步驟中,您將瞭解如何使用 Semantics 樹狀結構進行偵錯。

Semantics 樹狀結構

Compose 測試會使用稱為 Semantics 樹狀結構的結構來檢查畫面上的元素並讀取其屬性。無障礙服務採用的結構,因為如 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 樹狀結構時,您會看到有 3 個子元素的 SelectableGroup,這 3 個子元素是頂端應用程式列的分頁標籤。實際上,沒有任何 text 屬性具有值「ACCOUNTS」,因此測試失敗。不過,有每個分頁標籤的內容說明。您可以在 TopAppBar.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 樹狀結構偵錯。

壞消息:這項測試不實用!如果您仔細觀察 Semantics 樹狀結構,就會看到所有三個分頁標籤的內容說明 (不論分頁標籤是否選取)。我們必須更深入思考!

5. 合併及未合併的 Semantics 樹狀結構

Semantics 樹狀結構致力於盡可能精簡,且只會顯示相關資訊。

例如,我們的 TopAppBar 中,圖示和標籤不需要是不同的節點。查看「Overview」(總覽) 節點:

d20c96207c30e44a.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 尋找工具的未合併的 Semantics 樹狀結構。

@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」的節點,其父項為具有內容說明「Account」的節點。

請再次查看 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 (合併) 以及合併和未合併的 Semantics 樹狀結構。

6. 同步

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

我們會使用這個步驟的「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/OverviewScreen.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 所擁有的狀態。
  • 驗證狀態,而非行為。使用在使用者介面狀態上的斷言,而不是依賴已呼叫的物件和方式。

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

8. 後續步驟

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

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

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

預祝測試愉快!