Библиотека тестирования 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() ожидает завершения всех ожидающих фоновых сопрограмм.