Die androidx.a2ui.compose:compose-ui-testing-Testbibliothek bietet Test-APIs, die ein Controller-Muster verwenden, das für Jetpack-Testbibliotheken wie TestNavHostController von Navigation typisch ist.
Im Gegensatz zu Standard-Jetpack Compose-Komponenten, die statische Parameter verwenden und eine Benutzeroberfläche ausgeben, sind A2UI-Komponenten kontextbezogen. Sie basieren auf A2uiComponentScope, um dynamische Datenbindungen auszuwerten, ausgehende Aktionen an den Agent zu senden, in bidirektionale Datenbindungen zurückzuschreiben und dynamische untergeordnete Vorlagen zu erweitern.
Die Test-APIs vereinfachen die Testeinrichtung, da sie echte A2uiMessageProcessor-Instanzen bereitstellen und ihre Coroutinen in der Compose-Testumgebung ausführen.
Isolierte Komponenten
Sie können prüfen, ob eine einzelne Komponente ihre Daten auflöst, Aktionen auslöst und korrekt im Designsystem-Theme gerendert wird:
@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"])
}
Oberflächenstatus
Sie können Oberflächen-Hosts wie A2uiSurface testen, einschließlich ihrer Status und Übergänge:
@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()
}
Bidirektionale Bindung
Sie können Komponenten wie Textfelder testen, die während der Nutzereingabe in das Datenmodell zurückschreiben, und reaktive Aktualisierungen überprüfen, wenn der Agent das Datenmodell ändert:
@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()
}
Komponenten mit Vorlagen für untergeordnete Elemente
Sie können Komponenten testen, die Sammlungen von untergeordneten Elementen darstellen, die mit A2UI-Vorlagen ChildList definiert wurden:
@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()
}
Fehler-Fallbacks für Agent-Fehler
So können Sie prüfen, ob Oberflächen und Komponenten Agent-Fehler wie Halluzinationen angemessen behandeln:
@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)
}
Progressives Rendern
Sie können Zwischenstatus testen, in denen eine übergeordnete Komponente geladen wurde, untergeordnete Komponenten aber noch ausstehen:
@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()
}
Details zur Implementierung
In den folgenden Abschnitten werden Komponentenüberschreibungen, Schemavalidierung und Coroutine-Synchronisierung im Testframework erläutert.
Die Testbibliothek bietet die folgenden primären APIs:
A2uiTestController: Erweiterungskonstruktorfunktionen und Hauptschnittstelle des Testcontrollers.A2uiComponentStub: Stubs und Überschreibungen für untergeordnete Komponenten und Katalogkomponenten.A2uiTestSurface: Ein leichtgewichtiges, zusammensetzbares Dienstprogramm, das eine Testoberfläche einbindet.
Komponentenüberschreibungen im Vergleich zum Standard-Mocking
Um umfangreiche Drittanbieter-Mocking-Frameworks zu vermeiden, werden untergeordnete Komponenten und externe Abhängigkeiten mithilfe von UI-Stubs (A2uiComponentStub) umgangen. A2uiComponentStub.withId fängt eine bestimmte Komponenteninstanz nach ID ab, während A2uiComponentStub.withType das Rendern für einen gesamten Katalogtyp überschreibt.
Schnelle Schemavalidierung
Das Test-Framework erzwingt den A2UI-Protokollvertrag synchron. Wenn der Controller Komponenten initialisiert oder aktualisiert, wird A2uiCoreSchemaValidator für die bereitgestellten Nutzlasten ausgeführt. Wenn eine ungültige Eigenschaft festgelegt wird, z. B. ein fehlendes Pflichtfeld oder ein Typkonflikt, stürzt der Test sofort mit einem A2uiValidationException ab.
Coroutine-Synchronisierung
A2uiTestController.start wird in den Test-Coroutine-Kontext eingebunden, der von runComposeUiTest() bereitgestellt wird. Es extrahiert currentCoroutineContext(), ordnet Hintergrundschleifen einem separaten Job zu und wird automatisch abgebrochen, wenn der Testblock abgeschlossen ist. So werden nicht abgeschlossene Testläufe verhindert. waitForIdle()
wartet, bis alle ausstehenden Hintergrund-Coroutinen abgeschlossen sind.