androidx.a2ui.compose:compose-ui-testing 测试库提供的测试 API 使用了 Jetpack 测试库(例如 Navigation 的 TestNavHostController)惯用的控制器模式。
与采用静态参数并发出界面的标准 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"])
}
Surface 状态
您可以测试 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() 会等待所有待处理的后台协程完成。