Kiểm thử bố cục Compose

Việc kiểm thử giao diện người dùng hoặc màn hình là để xác minh hành vi chính xác của mã Compose, giúp cải thiện chất lượng ứng dụng bằng cách phát hiện lỗi sớm trong quá trình phát triển.

Compose cung cấp một bộ API kiểm thử để tìm các thành phần, xác minh thuộc tính của thành phần đó và thực hiện các hành động của người dùng. Các API này cũng bao gồm các tính năng nâng cao chẳng hạn như thao tác thời gian.

Ngữ nghĩa

Các quy trình kiểm thử giao diện người dùng trong Compose sử dụng ngữ nghĩa để tương tác với hệ phân cấp giao diện người dùng. Ngữ nghĩa (Semantics), cũng như ngụ ý trong tên gọi, cung cấp ý nghĩa cho một phần của giao diện người dùng. Ở đây, một "phần của giao diện người dùng" (hoặc phần tử) có thể có nghĩa là bất kỳ thành phần nào, từ một thành phần kết hợp đơn lẻ cho đến toàn bộ màn hình. Cây ngữ nghĩa (Semantics tree) được tạo kèm theo hệ phân cấp giao diện người dùng và mô tả hệ phân cấp này.

Sơ đồ cho thấy một bố cục giao diện người dùng điển hình và cách mà bố cục đó sẽ ánh xạ đến một cây ngữ nghĩa tương ứng

Hình 1. Một hệ phân cấp giao diện người dùng điển hình và cây ngữ nghĩa.

Khung ngữ nghĩa (semantics framework) chủ yếu được dùng để hỗ trợ tiếp cận. Vì vậy, các quy trình kiểm thử sẽ tận dụng những thông tin về hệ phân cấp giao diện người dùng được ngữ nghĩa hiển thị. Các nhà phát triển là người quyết định nội dung và mức độ được hiển thị.

Một nút nhấn chứa hình ảnh và văn bản

Hình 2. Một nút nhấn thông thường chứa biểu tượng và văn bản.

Chẳng hạn, đối với một nút nhấn như trên bao gồm một biểu tượng và một thành phần văn bản, cây ngữ nghĩa mặc định chỉ chứa nhãn văn bản "Like" (Thích). Điều này là do một số thành phần kết hợp, chẳng hạn như Text, đã hiển thị một số thuộc tính cho cây ngữ nghĩa. Bạn có thể thêm các thuộc tính vào cây ngữ nghĩa bằng cách sử dụng Modifier.

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

Thiết lập

Phần này mô tả cách thiết lập mô-đun để kiểm thử mã Compose.

Trước tiên, hãy thêm các phần phụ thuộc sau vào tệp build.gradle của mô-đun chứa các quy trình kiểm thử giao diện người dùng của bạn:

// 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")

Mô-đun này bao gồm ComposeTestRule và phương thức triển khai cho Android có tên là AndroidComposeTestRule. Thông qua quy tắc này, bạn có thể thiết lập nội dung Compose hoặc truy cập hoạt động. Quy trình kiểm thử giao diện người dùng điển hình của Compose có dạng như sau:

// 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 Kiểm thử

Có ba cách chính để tương tác với các phần tử:

  • Trình tìm kiếm (Finder) cho phép bạn chọn một hoặc nhiều phần tử (hoặc nút trong cây ngữ nghĩa) để tạo ra câu nhận định (assertion) hoặc thực hiện hành động với các phần tử đó.
  • Câu nhận định (Assertion) dùng để xác minh rằng các phần tử đó tồn tại hoặc có một số thuộc tính nhất định.
  • Hành động (Action) sẽ chèn các sự kiện người dùng được mô phỏng trên các phần tử, chẳng hạn như lượt nhấp hoặc các cử chỉ khác.

Một số API này chấp nhận một SemanticsMatcher để tham chiếu đến một hoặc nhiều nút trong cây ngữ nghĩa.

Trình tìm kiếm (Finder)

Bạn có thể sử dụng onNodeonAllNodes để chọn một hoặc nhiều nút (node) tương ứng; nhưng để thuận tiện hơn, bạn cũng có thể sử dụng các trình tìm kiếm đối với những nội dung tìm kiếm phổ biến nhất, chẳng hạn như onNodeWithText, onNodeWithContentDescription, v.v. Bạn có thể duyệt qua danh sách đầy đủ trong Bản tóm tắt về kiểm thử trong Compose.

Chọn một nút (node)

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

Chọn nhiều nút (node)

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

Sử dụng cây chưa hợp nhất

Một số nút hợp nhất thông tin ngữ nghĩa của các nút con. Ví dụ: một nút có hai phần tử văn bản sẽ hợp nhất các nhãn của hai phần tử này:

MyButton {
    Text("Hello")
    Text("World")
}

Từ một quy trình kiểm thử, chúng tôi có thể sử dụng printToLog() để hiển thị cây ngữ nghĩa:

composeTestRule.onRoot().printToLog("TAG")

Mã này in ra kết quả sau:

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

Nếu cần so khớp với một nút của cây chưa hợp nhất, bạn có thể thiết lập useUnmergedTree thành true:

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

Mã này in ra kết quả sau:

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]'

Tham số useUnmergedTree có sẵn trong tất cả trình tìm kiếm. Ví dụ: ở đoạn mã sau, tham số này được dùng trong trình tìm kiếm onNodeWithText.

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

Câu nhận định (Assertion)

Bạn có thể kiểm tra các câu nhận định bằng cách gọi hàm assert() trên SemanticsNodeInteraction được trả về bởi trình tìm kiếm với một hoặc nhiều trình so khớp (matcher):

// 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"))

Bạn cũng có thể sử dụng các hàm tiện lợi với những câu nhận định phổ biến nhất, chẳng hạn như assertExists, assertIsDisplayed, assertTextEquals, v.v. Bạn có thể duyệt qua danh sách đầy đủ trong Bản tóm tắt về kiểm thử trong Compose.

Ngoài ra còn có các hàm để kiểm tra các câu nhận định trên một tập hợp các nút:

// 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())

Hành động (Action)

Để chèn một hành động trên một nút, hãy gọi hàm perform…():

composeTestRule.onNode(...).performClick()

Dưới đây là một số ví dụ về hành động:

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

Bạn có thể duyệt qua danh sách đầy đủ trong Bản tóm tắt Kiểm thử Compose.

Trình so khớp (Matcher)

Phần này mô tả một số trình so khớp có sẵn để kiểm thử mã Compose

Trình so khớp phân cấp (Hierarchical matcher)

Trình so khớp phân cấp cho phép bạn đi lên hoặc xuống trên cây ngữ nghĩa và thực hiện so khớp đơn giản.

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

Dưới đây là một số ví dụ về các trình so khớp đang được sử dụng:

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

Trình chọn (Selector)

Một cách khác để tạo quy trình kiểm thử là sử dụng các trình chọn (selector), việc này có thể làm cho một số quy trình kiểm thử dễ đọc hơn.

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

Bạn có thể duyệt qua danh sách đầy đủ trong Bản tóm tắt Kiểm thử Compose.

Đồng bộ hoá

Các quy trình kiểm thử Compose được đồng bộ hoá theo mặc định với giao diện người dùng. Khi gọi một câu nhận định hoặc một hành động qua ComposeTestRule, quy trình kiểm thử sẽ được đồng bộ hoá trước đó, chờ cho đến khi cây giao diện người dùng không hoạt động.

Thông thường, bạn không phải thực hiện bất kỳ hành động nào. Tuy nhiên, có một số trường hợp phức tạp (edge case) mà bạn nên biết.

Khi một quy trình kiểm thử được đồng bộ hoá, ứng dụng Compose của bạn được đẩy nhanh thời gian bằng cách sử dụng đồng hồ ảo. Điều này có nghĩa là các quy trình kiểm thử Compose không chạy trong thời gian thực, cho nên các quy trình kiểm thử này có thể được thực hiện nhanh nhất trong khả năng.

Tuy nhiên, nếu bạn không sử dụng các phương thức đồng bộ hoá quy trình kiểm thử thì sẽ không xảy ra quá trình kết hợp lại và giao diện người dùng sẽ tạm dừng.

@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()
}

Một điều quan trọng cần lưu ý là yêu cầu này chỉ áp dụng cho hệ phân cấp Compose và không áp dụng cho phần còn lại của ứng dụng.

Tắt tính năng tự động đồng bộ hoá

Khi bạn gọi một câu nhận định (assertion) hoặc hành động thông qua ComposeTestRule chẳng hạn như assertExists(), quy trình kiểm thử của bạn sẽ được đồng bộ hoá với giao diện người dùng Compose. Trong một số trường hợp, bạn có thể dừng quá trình đồng bộ hoá này và tự điều khiển đồng hồ. Ví dụ: bạn có thể kiểm soát thời gian để chụp ảnh màn hình chính xác của một ảnh động tại thời điểm giao diện người dùng vẫn bận. Để tắt tính năng đồng bộ hoá tự động, hãy đặt thuộc tính autoAdvance trong mainClock thành false:

composeTestRule.mainClock.autoAdvance = false

Thông thường, sau đó bạn sẽ tự chuyển đến thời gian của mình. Bạn có thể chuyển đến chính xác một khung bằng advanceTimeByFrame() hoặc chuyển đến một khoảng thời gian cụ thể bằng advanceTimeBy():

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

Tài nguyên không tải (Idling resources)

Compose có thể đồng bộ hoá các quy trình kiểm thử và giao diện người dùng để mọi hành động và câu nhận định (assertion) được thực hiện ở trạng thái rảnh, chờ hoặc đẩy nhanh đồng hồ khi cần. Tuy nhiên, một số tác vụ không đồng bộ có kết quả ảnh hưởng đến trạng thái giao diện người dùng có thể chạy ở chế độ nền và quy trình kiểm thử không nhận biết được điều này.

Bạn có thể tạo và đăng ký các tài nguyên không tải này trong quy trình kiểm thử để các tài nguyên này có thể được xem xét khi quyết định xem ứng dụng đang kiểm thử đang bận hay ở trạng thái rảnh. Bạn không phải làm gì, trừ trường hợp cần đăng ký tài nguyên không đồng bộ bổ sung, chẳng hạn như khi chạy công việc trong nền không được đồng bộ hoá với Espresso hoặc Compose.

API này rất giống với Các tài nguyên không tải của Espresso để cho biết rằng đối tượng đang kiểm thử có đang rảnh hay bận. Sử dụng quy tắc kiểm thử Compose để đăng ký phương thức triển khai IdlingResource.

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

Đồng bộ hoá thủ công

Trong một số trường hợp, bạn phải đồng bộ hoá giao diện người dùng Compose với các phần khác trong quy trình kiểm thử hoặc ứng dụng đang kiểm thử.

waitForIdle chờ Compose ở trạng thái rảnh, nhưng điều này còn tuỳ thuộc vào thuộc tính 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

Xin lưu ý rằng trong cả hai trường hợp, waitForIdle cũng sẽ đợi các lượt vẽ và bố cục đang chờ xử lý.

Ngoài ra, bạn có thể chuyển đồng hồ cho đến khi đáp ứng một điều kiện nhất định bằng advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

Lưu ý rằng điều kiện trên phải được kiểm tra trạng thái có thể bị ảnh hưởng bởi đồng hồ này (chỉ hoạt động với trạng thái Compose). Mọi điều kiện phụ thuộc vào việc đo lường hoặc vẽ của Android (nghĩa là đo lường hoặc vẽ bên ngoài Compose) phải sử dụng khái niệm chung hơn, chẳng hạn như waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Mẫu kiểm thử phổ biến

Phần này mô tả một số phương pháp phổ biến mà bạn sẽ gặp khi tiến hành kiểm thử Compose.

Kiểm thử riêng biệt

ComposeTestRule cho phép bạn bắt đầu một hoạt động cho thấy thành phần kết hợp bất kỳ: toàn bộ ứng dụng, một màn hình đơn lẻ hoặc một phần tử nhỏ. Đây là một phương pháp hay để kiểm tra xem các thành phần kết hợp có được đóng gói chính xác và hoạt động độc lập hay không, cho phép việc kiểm thử giao diện người dùng trở nên dễ dàng hơn và tập trung hơn.

Điều này không có nghĩa là bạn chỉ nên tạo các quy trình kiểm thử đơn vị khi kiểm thử giao diện người dùng. Việc kiểm thử trong phạm vi các phần lớn hơn trên giao diện người dùng cũng rất quan trọng.

Truy cập vào hoạt động và tài nguyên sau khi thiết lập nội dung của riêng bạn

Thông thường, bạn cần thiết lập nội dung đang kiểm thử bằng composeTestRule.setContent, đồng thời cần truy cập vào các tài nguyên hoạt động, chẳng hạn như để xác nhận rằng văn bản hiển thị khớp với tài nguyên chuỗi. Tuy nhiên, bạn không thể gọi setContent trên một quy tắc được tạo bằng createAndroidComposeRule() nếu hoạt động này đã gọi quy tắc đó.

Mẫu phổ biến dùng để thực hiện việc này là tạo AndroidComposeTestRule bằng một hoạt động trống (chẳng hạn như ComponentActivity).

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()
    }
}

Xin lưu ý rằng bạn cần thêm ComponentActivity vào tệp AndroidManifest.xml của ứng dụng. Bạn có thể làm việc đó bằng cách thêm phần phụ thuộc này vào mô-đun:

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

Thuộc tính ngữ nghĩa tuỳ chỉnh

Bạn có thể tạo các thuộc tính ngữ nghĩa tuỳ chỉnh để hiển thị thông tin cho quy trình kiểm thử. Để thực hiện việc này, hãy khai báo một SemanticsPropertyKey mới và cung cấp thuộc tính đó bằng cách sử dụng SemanticsPropertyReceiver.

// Creates a Semantics property of type boolean
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

Bạn hiện có thể sử dụng thuộc tính đó bằng chỉ định truy cập semantics:

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

Từ các quy trình kiểm thử, bạn có thể sử dụng SemanticsMatcher.expectValue để xác nhận giá trị của thuộc tính:

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

Xác minh quá trình khôi phục trạng thái

Bạn phải xác minh rằng trạng thái của các phần tử Compose được khôi phục chính xác khi hoạt động hoặc quy trình được tạo lại. Quá trình xác minh này có thể được thực hiện mà không cần tạo lại hoạt động bằng lớp StateRestorationTester.

Với lớp này, bạn có thể mô phỏng quá trình tạo lại một thành phần kết hợp. Việc xác minh quá trình triển khai rememberSaveable sẽ đặc biệt hữu ích.


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.
    }
}

Gỡ lỗi

Cách chính để giải quyết các vấn đề trong quy trình kiểm thử là xem xét cây ngữ nghĩa. Bạn có thể in cây ngữ nghĩa bằng cách gọi composeTestRule.onRoot().printToLog() bất kỳ lúc nào trong quy trình kiểm thử. Hàm này in nhật ký như sau:

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'

Các nhật ký này chứa những thông tin có giá trị giúp theo dõi lỗi.

Khả năng tương tác với Espresso

Trong một ứng dụng kết hợp, bạn có thể tìm thấy các thành phần Compose bên trong các hệ phân cấp chế độ xem; cũng như có thể tìm thấy các thành phần hiển thị bên trong các thành phần kết hợp Compose (thông qua thành phần kết hợp AndroidView).

Không cần thực hiện bước đặc biệt nào để so khớp một trong hai loại này. Bạn so khớp các thành phần hiển thị thông qua onView của Espresso, và so khớp các phần tử Compose thông qua ComposeTestRule.

@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()))
}

Khả năng tương tác với UiAutomator

Theo mặc định, chỉ có thể truy cập thành phần kết hợp UiAutomator bằng các trình mô tả thuận tiện của chúng (văn bản hiển thị, thông tin mô tả nội dung, v.v.). Nếu muốn truy cập bất kỳ thành phần kết hợp nào bằng Modifier.testTag, bạn cần bật thuộc tính ngữ nghĩa testTagAsResourceId cho cây con của thành phần kết hợp cụ thể. Việc kích hoạt hành vi này hữu ích đối với các thành phần kết hợp không có tên người dùng duy nhất nào khác, chẳng hạn như thành phần kết hợp có thể cuộn (ví dụ: LazyColumn).

Bạn chỉ có thể bật tính năng này một lần trong hệ phân cấp thành phần kết hợp để đảm bảo mọi thành phần kết hợp lồng với Modifier.testTag đều có thể truy cập được từ 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
    }
}

Bạn có thể truy cập mọi thành phần kết hợp với Modifier.testTag(tag) bằng cách sử dụng By.res(resourceName) dùng tag giống như resourceName.

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

Tìm hiểu thêm

Để tìm hiểu thêm, hãy thử tham gia Lớp học lập trình Kiểm thử trong Jetpack Compose.

Mẫu