A2UI bileşenlerini ve yüzeylerini test etme

androidx.a2ui.compose:compose-ui-testing Test kitaplığı, Jetpack test kitaplıklarına özgü bir denetleyici kalıbı kullanan test API'leri sağlar. Örneğin, Navigation'ın TestNavHostController kitaplığı bu kalıbı kullanır.

Statik parametreler alan ve kullanıcı arayüzü yayan standart Jetpack Compose bileşenlerinin aksine A2UI bileşenleri bağlama duyarlıdır. Dinamik veri bağlamalarını değerlendirmek, giden işlemleri aracıya göndermek, iki yönlü veri bağlamalarına geri yazmak ve dinamik alt şablonları genişletmek için A2uiComponentScope kullanırlar.

Test API'leri, gerçek A2uiMessageProcessor örnekleri sağlarken ve Compose test ortamına bağlı olan eş yordamlarını çalıştırırken test kurulumunu basitleştirir.

Yalıtılmış bileşenler

Tek bir bileşenin verilerini çözdüğünü, işlemleri gönderdiğini ve tasarım sistemi temanızda doğru şekilde oluşturduğunu doğrulayabilirsiniz:

@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"])
}

Yüzey durumları

A2uiSurface gibi yüzey ana makinelerini durumları ve geçişleri de dahil olmak üzere test edebilirsiniz:

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

İki yönlü bağlama

Kullanıcı girişi sırasında veri modeline geri yazan metin alanları gibi bileşenleri test edebilir ve aracı veri modelini değiştirdiğinde reaktif güncellemeleri doğrulayabilirsiniz:

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

Şablonlu alt öğeler içeren bileşenler

A2UI ChildList şablonları kullanılarak tanımlanan çocuk koleksiyonlarını görüntülemek için tasarlanmış bileşenleri test edebilirsiniz:

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

Ajan hataları için hata geri dönüşleri

Yüzeylerin ve bileşenlerin, halüsinasyon gibi aracı hatalarını düzgün şekilde işlediğini doğrulayabilirsiniz:

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

Aşamalı oluşturma

Bir üst bileşenin yüklendiği ancak alt bileşenlerin hâlâ beklemede olduğu ara durumları test edebilirsiniz:

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

Uygulama ayrıntıları

Aşağıdaki bölümlerde, test çerçevesindeki bileşen geçersiz kılmaları, şema doğrulaması ve eş yordam eşitlemesi açıklanmaktadır.

Test kitaplığı aşağıdaki temel API'leri sunar:

  • A2uiTestController: Uzantı oluşturucu işlevleri ve ana test denetleyicisi arayüzü.
  • A2uiComponentStub: Alt ve katalog bileşenleri için saplar ve geçersiz kılmalar.
  • A2uiTestSurface: Bir test yüzeyi oluşturan, hafif ve birleştirilebilir bir yardımcı program.

Bileşen geçersiz kılmaları ve standart sahte veriler

Ağır üçüncü taraf sahte çerçeveleri ortadan kaldırmak için alt bileşenler ve harici bağımlılıklar, kullanıcı arayüzü saplamaları (A2uiComponentStub) kullanılarak atlanır. A2uiComponentStub.withId, belirli bir bileşen örneğini kimliğe göre yakalarken A2uiComponentStub.withType, tüm katalog türü için oluşturmayı geçersiz kılar.

Hızlı hata veren şema doğrulaması

Test çerçevesi, A2UI protokol sözleşmesini eşzamanlı olarak zorunlu kılar. Denetleyici, bileşenleri başlattığında veya güncellediğinde sağlanan yükler üzerinde A2uiCoreSchemaValidator çalıştırır. Eksik zorunlu alan veya tür uyuşmazlığı gibi geçersiz bir özellik ayarlanırsa test, A2uiValidationException ile hemen çöker.

Coroutine senkronizasyonu

A2uiTestController.start, runComposeUiTest() tarafından sağlanan test eş yordam bağlamına bağlanır. Bu işlev, currentCoroutineContext() öğesini ayıklar, arka plan döngülerini ayrılmış bir Job ile eşler ve test bloğu tamamlandığında kendisini otomatik olarak iptal ederek askıda kalan test yürütmelerini önler. waitForIdle() Bekleyen tüm arka plan eş yordamlarının tamamlanmasını bekler.