測試 UI 或螢幕會用來驗證撰寫程式碼的正確行為,可在開發程序早期偵測錯誤,改善應用程式的品質。
Compose 提供一組測試 API 來找出元素、驗證其屬性,以及執行使用者動作。以及時間管理等進階功能。
語義學
Compose 中的 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 的一般 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
來參照語意樹狀結構中的一或多個節點。
搜尋器
您可以分別使用 onNode
和 onAllNodes
來選取一個或多個節點,亦可以使用方便的搜尋工具進行一般的搜尋,例如 onNodeWithText
、onNodeWithContentDescription
等。請前往 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"))
您也可以使用便利的函式處理最常見的斷言,例如 assertExists
、assertIsDisplayed
、assertTextEquals
等。請前往 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 }
常見模式
本節將說明 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 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()
驗證狀態還原
重新建立活動或程序後,請確認 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.
}
}
偵錯
如要在測試中解決問題,主要方法是查看語意樹狀結構。您可以在測試的任何時間點呼叫 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
的可組合項,則需為特定的可組合項子樹狀結構啟用語意屬性 testTagAsResourceId
。如果可組合項並無其他任何專屬控制代碼,例如捲動式可組合項 (例如: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 程式碼研究室。