Kiểm thử các thành phần và nền tảng A2UI

Thư viện kiểm thử androidx.a2ui.compose:compose-ui-testing cung cấp các API kiểm thử sử dụng một mẫu trình điều khiển đặc trưng cho các thư viện kiểm thử Jetpack, chẳng hạn như TestNavHostController của Navigation.

Không giống như các thành phần Jetpack Compose tiêu chuẩn nhận các tham số tĩnh và phát ra giao diện người dùng, các thành phần A2UI mang tính ngữ cảnh. Chúng dựa vào A2uiComponentScope để đánh giá các liên kết dữ liệu động, gửi các hành động đi ra cho tác nhân, ghi lại các liên kết dữ liệu hai chiều và tăng các mẫu con động.

Các API kiểm thử giúp đơn giản hoá quá trình thiết lập kiểm thử trong khi cung cấp các thực thể A2uiMessageProcessor thực, chạy các coroutine được liên kết với môi trường thử nghiệm Compose.

Thành phần riêng biệt

Bạn có thể xác minh rằng một thành phần riêng lẻ giải quyết dữ liệu, gửi các thao tác và hiển thị chính xác trong giao diện hệ thống thiết kế của bạn:

@Test
fun button_resolvesStubChildAndDispatchesAction() = runComposeUiTest {
    // 1. Create the test controller
    val controller = A2uiTestController(
        // Provide a catalog containing the component under test
        catalog = CustomComponentCatalog,
        // Configure the component under test with concrete properties
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "Button",
                properties = mapOf(
                    "child" to "btn_text",
                    "variant" to "primary",
                    "action" to mapOf(
                        "event" to mapOf(
                            "name" to "submit_form",
                            "context" to mapOf("username" to mapOf("path" to "/user/name")),
                        ),
                    ),
                ),
            ),
            A2uiComponentPayload("btn_text"),
        ),
        // Stub the required child component
        componentStubs = listOf(
            A2uiComponentStub.withId("btn_text") { _, modifier ->
                Text("Submit", modifier = modifier)
            },
        ),
        // Provide initial dynamic data
        initialData = mapOf("user" to mapOf("name" to "Test User")),
    )

    // 2. Start background processing and initialize the surface
    val surface = controller.start()

    // 3. Mount the UI
    setContent {
        A2uiTestSurface(surface)
    }

    // 4. Interact using standard Compose UI semantics
    onNodeWithText("Submit").performClick()

    // 5. Wait for Compose and A2UI background processes to settle
    waitForIdle()
    controller.waitForIdle()

    // 6. Assert outbound actions were correctly evaluated and intercepted
    val action = controller.dispatchedActions.single() as A2uiEventAction
    assertEquals("submit_form", action.eventName)
    assertEquals("Test User", action.context["username"])
}

Trạng thái bề mặt

Bạn có thể kiểm thử các thành phần lưu trữ trên nền tảng như A2uiSurface, bao gồm cả trạng thái và quá trình chuyển đổi của chúng:

@Test
fun surface_displaysLoading_thenTransitionsToContent() = runComposeUiTest {
    // 1. Create an empty controller to simulate a pending network request
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        // Pre-register a stub for the expected root component type
        componentStubs = listOf(
            A2uiComponentStub.withType("RootLayout") { _, modifier ->
                Text("Content Ready", modifier = modifier)
            },
        ),
    )
    val surface = controller.start()

    // 2. Mount the surface UI
    setContent {
        A2uiSurface(surfaceModel = surface)
    }

    // 3. Assert the loading placeholder is active
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertExists()

    // 4. Simulate the agent pushing the layout payload over the network
    controller.updateComponent(
        id = "root",
        type = "RootLayout",
        properties = emptyMap(),
    )

    // 5. Wait for the data layer and animation to settle
    controller.waitForIdle()
    waitForIdle()

    // 6. Assert the loading state is gone and content is visible
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
    onNodeWithText("Content Ready").assertIsDisplayed()
}

Liên kết hai chiều

Bạn có thể kiểm thử các thành phần như trường văn bản ghi lại vào mô hình dữ liệu trong quá trình hoạt động đầu vào của người dùng và xác minh các bản cập nhật phản ứng khi tác nhân thay đổi mô hình dữ liệu:

@Test
fun textField_writesToDataModelAndReactsToAgent() = runComposeUiTest {
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "TextField",
                properties = mapOf(
                    "label" to "Username",
                    "value" to mapOf("path" to "/form/username"),
                ),
            ),
        ),
        initialData = mapOf("form" to mapOf("username" to "Initial")),
    )
    val surface = controller.start()

    setContent {
        A2uiTestSurface(surface)
    }

    // 1. User interaction updates the global DataModel locally
    onNodeWithText("Initial").performTextReplacement("LocallyTyped")
    waitForIdle()

    // 2. Assert the component wrote back to the DataModel
    assertEquals("LocallyTyped", controller.getData<String>("/form/username"))

    // 3. Simulate the agent pushing a data update for the same path
    controller.updateData("/form/username", "ServerOverridden")
    controller.waitForIdle()

    // 4. Assert the component reactively updated the UI
    onNodeWithText("ServerOverridden").assertIsDisplayed()
}

Các thành phần có các thành phần con được tạo mẫu

Bạn có thể kiểm thử các thành phần được thiết kế để hiển thị các tập hợp con được xác định bằng cách sử dụng mẫu A2UI ChildList:

@Test
fun column_rendersDynamicChildTemplates() = runComposeUiTest {
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        initialData = mapOf(
            "catalog" to mapOf(
                "products" to listOf(
                    mapOf("title" to "Camera"),
                    mapOf("title" to "Laptop"),
                ),
            ),
        ),
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "Column",
                properties = mapOf(
                    "children" to mapOf(
                        "path" to "/catalog/products",
                        "componentId" to "product_template",
                    ),
                ),
            ),
            // Bind the initial properties for the dynamically instantiated
            // template stub.
            A2uiComponentPayload(
                id = "product_template",
                properties = mapOf("title" to mapOf("path" to "title")),
            ),
        ),
        componentStubs = listOf(
            A2uiComponentStub.withId(id = "product_template") { props, modifier ->
                val titleProp = remember { A2uiProperty.dynamicString("title") }
                val title = props.bind(titleProp) ?: "Unknown"
                Text(text = "Stubbed: $title", modifier = modifier)
            },
        ),
    )
    val surface = controller.start()
    setContent { A2uiTestSurface(surface) }

    // Verify the template was instantiated twice with relative data
    onNodeWithText("Stubbed: Camera").assertExists()
    onNodeWithText("Stubbed: Laptop").assertExists()

    // Simulate appending a new item to the data model array
    controller.updateData("/catalog/products/-", mapOf("title" to "Tablet"))
    controller.waitForIdle()

    // Verify the Column dynamically instantiated a new child stub
    onNodeWithText("Stubbed: Tablet").assertExists()
}

Lỗi dự phòng cho lỗi của tác nhân

Bạn có thể xác minh rằng các nền tảng và thành phần xử lý lỗi của tác nhân (chẳng hạn như ảo giác) một cách hiệu quả:

@Test
fun surface_displaysErrorFallback_onAgentHallucination() = runComposeUiTest {
    val controller = A2uiTestController(catalog = CustomComponentCatalog)
    val surface = controller.start()

    // 1. Mount the surface orchestrator with error boundaries
    setContent { A2uiSurface(surfaceModel = surface) }

    // 2. Simulate an agent hallucinating a broken component layout
    controller.failComponent(
        id = "root",
        exception = A2uiException.A2uiValidationException(
            message = "HallucinatedType",
            path = "/components/root"
        ),
    )
    controller.waitForIdle()

    // 3. Assert the surface displayed the fallback error state
    onNodeWithText("Failed to load: HallucinatedType").assertIsDisplayed()

    // 4. Assert the core layer dispatched an error to the server
    val errorMsg = controller.outboundErrors.single()
    assertEquals("VALIDATION_FAILED", errorMsg.code)
}

Kết xuất tăng dần

Bạn có thể kiểm thử các trạng thái trung gian khi một thành phần mẹ đã tải nhưng các thành phần con vẫn đang chờ xử lý:

@Test
fun progressiveRendering_parentRendersWhileChildIsPending() = runComposeUiTest {
    // 1. Mount the parent, omitting the child instance
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "Button",
                properties = mapOf(
                    "child" to "delayed_text_id",
                    "action" to mapOf("event" to mapOf("name" to "click")),
                ),
            ),
        ),
    )
    val surface = controller.start()
    setContent {
        A2uiTestSurface(surface)
    }

    // 2. Initial state: parent is rendered, child displays loading state
    onNodeWithText("Submit").assertDoesNotExist()
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertExists()

    // 3. Simulate arrival of the child component
    controller.updateComponent(
        id = "delayed_text_id",
        type = "Text",
        properties = mapOf("text" to "Submit"),
    )
    controller.waitForIdle()

    // 4. Assert that progressive rendering completed
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
    onNodeWithText("Submit").assertIsDisplayed()
}

Thông tin chi tiết về việc triển khai

Các phần sau đây giải thích về việc ghi đè thành phần, xác thực giản đồ và đồng bộ hoá coroutine trong khung kiểm thử.

Thư viện kiểm thử giới thiệu các API chính sau đây:

  • A2uiTestController: Các hàm khởi tạo tiện ích và giao diện bộ điều khiển kiểm thử chính.
  • A2uiComponentStub: Các phần giữ chỗ và ghi đè cho các thành phần con và thành phần danh mục.
  • A2uiTestSurface: Một tiện ích kết hợp gọn nhẹ giúp gắn một bề mặt kiểm thử.

Ghi đè thành phần so với mô phỏng tiêu chuẩn

Để loại bỏ các khung mô phỏng nặng của bên thứ ba, các thành phần con và các phần phụ thuộc bên ngoài sẽ bị bỏ qua bằng cách sử dụng các phần giữ chỗ giao diện người dùng (A2uiComponentStub). A2uiComponentStub.withId chặn một phiên bản thành phần cụ thể theo mã nhận dạng, trong khi A2uiComponentStub.withType ghi đè quá trình kết xuất cho toàn bộ loại danh mục.

Xác thực giản đồ thất bại nhanh

Khung kiểm thử thực thi hợp đồng giao thức A2UI một cách đồng bộ. Khi bộ điều khiển khởi động hoặc cập nhật các thành phần, bộ điều khiển sẽ chạy A2uiCoreSchemaValidator dựa trên các tải trọng được cung cấp. Nếu bạn đặt một thuộc tính không hợp lệ, chẳng hạn như thiếu trường bắt buộc hoặc loại không khớp, thì kiểm thử sẽ gặp sự cố ngay lập tức với A2uiValidationException.

Đồng bộ hoá coroutine

A2uiTestController.start liên kết với ngữ cảnh coroutine kiểm thử do runComposeUiTest() cung cấp. Thao tác này sẽ trích xuất currentCoroutineContext(), ánh xạ các vòng lặp nền đến một Job tách biệt và tự động huỷ khi khối kiểm thử hoàn tất, ngăn chặn các lượt thực thi kiểm thử bị treo. waitForIdle() đợi tất cả các coroutine đang chờ xử lý ở chế độ nền hoàn tất.