Тестирование компонентов и интерфейсов A2UI.

Библиотека тестирования androidx.a2ui.compose:compose-ui-testing предоставляет API для тестирования, использующие шаблон контроллера, характерный для библиотек тестирования Jetpack, таких как TestNavHostController из библиотеки Navigation.

В отличие от стандартных компонентов Jetpack Compose, которые принимают статические параметры и генерируют пользовательский интерфейс, компоненты A2UI являются контекстными. Они используют A2uiComponentScope для оценки динамических привязок данных, отправки исходящих действий агенту, записи обратно в двусторонние привязки данных и создания динамических дочерних шаблонов.

API для тестирования упрощают настройку тестов, одновременно предоставляя реальные экземпляры A2uiMessageProcessor , запускающие свои сопрограммы, привязанные к тестовой среде Compose.

Изолированные компоненты

В рамках вашей темы оформления вы можете убедиться, что отдельный компонент корректно обрабатывает свои данные, отправляет действия и отображается правильно:

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

Поверхностные состояния

Вы можете тестировать такие платформы, как A2uiSurface , включая их состояния и переходы:

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

Двустороннее крепление

Вы можете тестировать такие компоненты, как текстовые поля, которые записывают данные обратно в модель данных во время ввода пользователем, и проверять реактивные обновления, когда агент изменяет модель данных:

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

Компоненты с дочерними элементами, созданными по шаблону.

Вы можете протестировать компоненты, предназначенные для отображения коллекций дочерних элементов, определенных с помощью шаблонов 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()
}

Резервные механизмы обработки ошибок для случаев ошибок агента

Вы можете убедиться, что поверхности и компоненты корректно обрабатывают ошибки агентов, такие как галлюцинации:

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

Прогрессивная отрисовка

Вы можете протестировать промежуточные состояния, когда родительский компонент загружен, но дочерние компоненты все еще находятся в состоянии ожидания:

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

Детали реализации

В следующих разделах описываются переопределения компонентов, проверка схемы и синхронизация сопрограмм в тестовой среде.

Библиотека тестирования предоставляет следующие основные API:

  • A2uiTestController : Функции конструктора расширения и основной интерфейс контроллера тестирования.
  • A2uiComponentStub : Заглушки и переопределения для дочерних компонентов и компонентов каталога.
  • A2uiTestSurface : Легковесная, компонуемая утилита, которая монтирует тестовую поверхность.

Переопределение компонентов против стандартного мокирования

Для устранения необходимости использования ресурсоемких сторонних фреймворков для создания заглушек, дочерние компоненты и внешние зависимости обходятся с помощью заглушек пользовательского интерфейса ( A2uiComponentStub ). A2uiComponentStub.withId перехватывает конкретный экземпляр компонента по ID, а A2uiComponentStub.withType переопределяет рендеринг для всего типа каталога.

Проверка схемы с быстрым отказом

Тестовая среда синхронно проверяет соблюдение контракта протокола A2UI. Когда контроллер инициализирует или обновляет компоненты, он запускает A2uiCoreSchemaValidator для предоставленных данных. Если задано недопустимое свойство, например, отсутствует обязательное поле или несоответствие типов, тест немедленно завершается с ошибкой A2uiValidationException .

Синхронизация сопрограмм

A2uiTestController.start подключается к контексту сопрограммы теста, предоставляемому функцией runComposeUiTest() . Он извлекает currentCoroutineContext() , сопоставляет фоновые циклы с отсоединенным Job и автоматически отменяет себя после завершения тестового блока, предотвращая незавершенные выполнения тестов. waitForIdle() ожидает завершения всех ожидающих фоновых сопрограмм.