A2UI コンポーネントとサーフェスをテストする

androidx.a2ui.compose:compose-ui-testing テスト ライブラリは、Navigation の TestNavHostController など、Jetpack テスト ライブラリに特有のコントローラ パターンを使用するテスト API を提供します。

静的パラメータを受け取って UI を出力する標準の 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: テスト サーフェスをマウントする軽量のコンポーザブル ユーティリティ。

コンポーネントのオーバーライドと標準のモック

重いサードパーティのモック フレームワークを排除するため、UI スタブ(A2uiComponentStub)を使用して子コンポーネントと外部依存関係をバイパスします。A2uiComponentStub.withId は ID で特定のコンポーネント インスタンスをインターセプトし、A2uiComponentStub.withType はカタログ タイプ全体のレンダリングをオーバーライドします。

フェイルファスト スキーマ検証

テスト フレームワークは、A2UI プロトコル契約を同期的に適用します。コントローラがコンポーネントを初期化または更新すると、提供されたペイロードに対して A2uiCoreSchemaValidator が実行されます。必須フィールドの欠落や型の不一致など、無効なプロパティが設定されている場合、テストは A2uiValidationException で直ちにクラッシュします。

コルーチンの同期

A2uiTestController.start は、runComposeUiTest() によって提供されるテスト コルーチン コンテキストにフックします。currentCoroutineContext() を抽出し、バックグラウンド ループをデタッチされた Job にマッピングします。テストブロックが完了すると自動的にキャンセルされ、テスト実行がハングアップするのを防ぎます。waitForIdle() は、保留中のバックグラウンド コルーチンがすべて完了するまで待機します。