テストを同期する

Compose テストは、デフォルトで UI と同期されます。ComposeTestRule を使用してアサーションまたはアクションを呼び出すと、テストは事前に同期され、UI ツリーがアイドル状態になるのを待機します。

通常は、何もする必要はありません。ただし、知っておくべきエッジケースがいくつかあります。

テストが同期されると、Compose アプリは仮想クロックを使用して時間を進めます。つまり、Compose テストはリアルタイムで実行されないため、可能な限り早く結果を出すことができます。

ただし、テストを同期するメソッドを使用しなかった場合は、再コンポジションが発生せず、UI が一時停止しているように見えます。

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions.
    var lastSeenValue = 0 // Used to track recompositions.
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition.

    // Fails because nothing triggered a recomposition.
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition.
    composeTestRule.onNodeWithText("1").assertExists()
}

この要件が適用されるのは Compose 階層のみで、アプリの他の部分は適用対象外であることにも注意してください。

自動同期を無効にする

assertExists() などの ComposeTestRule を介してアサーションまたはアクションを呼び出すと、テストは Compose UI と同期されます。場合によっては、この同期を停止して、手動でクロックを制御できます。たとえば、UI がまだビジー状態である時点で、アニメーションの正確なスクリーンショットを撮る時間を制御できます。自動同期を無効にするには、mainClock の autoAdvance プロパティを false に設定します。

composeTestRule.mainClock.autoAdvance = false

この場合、通常は手動で時間を進めます。advanceTimeByFrame() を使用してフレームを正確に 1 つだけ進めたり、advanceTimeBy() を使用して進める時間を指定したりできます。

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

アイドル状態のリソース

Compose は、テストと UI を同期することにより、すべてのアクションとアサーションがアイドル状態で実行され、必要に応じてクロックを待機させるか進めるようにすることができます。ただし、結果が UI 状態に影響する一部の非同期オペレーションは、テストによって認識されていないときにバックグラウンドで実行される可能性があります。

このようなアイドリング リソースをテスト内で作成して登録すると、テスト対象のアプリがビジー状態かアイドル状態かを判断する際に、それらのリソースが考慮されます。Espresso または Compose と同期されないバックグラウンド ジョブを実行する場合など、追加のアイドリング リソースを登録する必要がない場合は、何もする必要はありません。

この API は Espresso のアイドリング リソースとよく似ており、テスト対象がアイドル状態かビジー状態かを示します。IdlingResource の実装を登録するには、Compose テストルールを使用します。

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

手動同期

特定のケースでは、Compose UI をテストの他の部分またはテスト対象のアプリと同期する必要があります。

waitForIdle() 関数は Compose がアイドル状態になるのを待機しますが、この関数は autoAdvance プロパティに依存します。

composeTestRule.mainClock.autoAdvance = true // Default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle.

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for idling resources to become idle.

どちらの場合も、waitForIdle() は保留中の描画パスとレイアウトパスも待機します。

また、advanceTimeUntil() を使用して、特定の条件が満たされるまでクロックを進めることもできます。

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

特定の条件は、このクロックに影響される可能性がある状態をチェックするものでなければなりません(これは Compose 状態でのみ機能します)。

アニメーション テストを最適化する

高忠実度のアニメーションをテストする場合、自動進行を無効にして、フレームを手動でステップ実行し、中間 UI 状態をアサートする必要があることがよくあります。このようなフレームごとのループでは、runWithoutImplicitWait メソッドを使用してアサーションを実行します。標準のノードクエリ(onNodeWithTag や fetchSemanticsNode など)は、クロックを手動で制御している場合は冗長な暗黙的な同期をトリガーするため、これらをバイパスするとテストの実行時間が大幅に短縮されます。

使用上のガイドライン

  • 手動クロック管理: mainClock.autoAdvance が false に設定され、UI が現在のフレームで既知の安定した状態にある場合は、この API を使用します。
  • UI スレッドの実行: UI ツリーの安定性を確保するには、runOnUiThread などを使用して、UI スレッドで runWithoutImplicitWait を呼び出します。UI スレッド外で実行すると、テストが競合状態や古い状態の読み取りにさらされます。
  • 読み取り専用アサーション: ブロックには、読み取り専用アサーションのみを含める必要があります。状態を変更するアクションは、このブロックの外部で実行する必要があります。

例

@Test
fun runWithoutImplicitWaitSample() = runComposeUiTest {
    setContent { MainScreen() }
    mainClock.autoAdvance = false

    // Trigger an animation
    onNodeWithText("Start Animation").performClick()

    // Step through the animation frame-by-frame
    while (hasPendingWork()) {
        mainClock.advanceTimeByFrame()
        waitForIdle()
        runOnUiThread {
            // Suppress implicit synchronization inside this block to avoid redundant
            // waits on each node query, making the frame assertions execute much faster.
            runWithoutImplicitWait {
                val box1 = onNodeWithTag("Box1").fetchSemanticsNode()
                val box2 = onNodeWithTag("Box2").fetchSemanticsNode()
                val box3 = onNodeWithTag("Box3").fetchSemanticsNode()

                // Assert the exact intermediate state of all three properties for this frame
                assert(box1.boundsInRoot.right <= box2.boundsInRoot.left)
                assert(box2.boundsInRoot.right <= box3.boundsInRoot.left)
            }
        }
    }
}

メインスレッドの同期

Compose テストでメインスレッドの同期がサポートされるようになり、メインスレッドから直接 waitForIdle(ひいては Compose UI のアクションとアサーション)を安全に呼び出せるようになりました。

以前の Compose テストでは、2 スレッドモデルが厳密に適用されていました。テスト実行はバックグラウンド テストスレッドで行われ、UI の更新はメインスレッドで行われていました。メインスレッドから waitForIdle や runOnIdle などの同期メソッドを呼び出すと(たとえば、runOnUiThread ブロック内で)、IllegalStateException がスローされます。これは、フレームワークがメインスレッドの同期を防ぐために厳格なスレッド チェックを適用しているためです。

メインスレッドの同期が有効になっていると、Compose テスト フレームワークは、メインスレッドでブロッキング呼び出しが行われた場合でも、クロックを進めて保留中の処理を実行できるようになりました。

メインスレッドの同期を使用するタイミング

純粋な Compose テストでは、バックグラウンド スレッドでテストを維持することが標準ですが、メインスレッドの同期は、いくつかの特定のシナリオで非常に有効です。

  • 複雑なビューの相互運用性: Compose と従来の Android ビューの両方を含むハイブリッド UI をテストする場合、ビューを操作するにはメインスレッドで実行する必要があります。スレッド コンテキストを常に切り替えることなく、ビューを操作して Compose ノードを順番にアサートできるようになりました。
  • 同期状態のミューテーション: アーキテクチャがメインスレッドに厳密にバインドされた状態ホルダーに依存している場合、メインスレッドを離れることなく、状態をミューテーションして Compose UI が安定するのをすぐに待機できるようになりました。
  • カスタム テスト ランナー: カスタム テスト インフラストラクチャを構築している場合や、テスト ランナーがメインスレッドで実行される環境を利用している場合、Compose テストはバックグラウンド スレッドの委任を必要とせずにクリーンに実行されるようになりました。

例

従来は、メインスレッドでの同期が厳しく禁止されていたため、デベロッパーはバックグラウンドのテストランナー スレッドと UI スレッドの間を行き来する必要があり、テストが分断されていました。

@Test
fun testBidirectionalInteropUIUpdates_old() {
    val scenario = launchFragmentInContainer<InteropFragment>()
    composeTestRule.waitForIdle()
    scenario.onFragment { fragment ->
        fragment.legacyButton.performClick()
    }
    // Jump to Test Thread to verify state settles inside compose
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed()
    composeTestRule.onNodeWithText("Increment Legacy TextView").performClick()
    composeTestRule.waitForIdle()
    // Jump back to Main Thread to verify target view state settles
    scenario.onFragment { fragment ->
        assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1")
    }
}

メインスレッドの同期を有効にすると、Compose 階層と View 階層のアサーションを同じブロックで実行できます。

@Test
fun testBidirectionalInteropUIUpdates_new() {
    val scenario = launchFragmentInContainer<InteropFragment>()
    composeTestRule.waitForIdle()
    scenario.onFragment { fragment ->
        fragment.legacyButton.performClick()
        composeTestRule.waitForIdle()
        composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed()
        composeTestRule.onNodeWithText("Increment Legacy TextView").performClick()
        composeTestRule.waitForIdle()
        assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1")
    }
}

条件を待つ

データの読み込みや Android の測定または描画(つまり、Compose の外部の測定または描画)など、外部の処理に依存する条件では、waitUntil() のようなより一般的なコンセプトを採用する必要があります。

composeTestRule.waitUntil(timeoutMs) { condition }

また、waitUntil ヘルパーを使用することもできます。

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

参考情報

  • Android でアプリをテストする: Android テストのメイン ランディング ページで、テストの基礎と手法についてより広範な視点から説明しています。
  • テストの基礎: Android アプリのテストの背景にある基本概念について詳しく学べます。
  • ローカルテスト: 一部のテストは、独自のワークステーションでローカルに実行できます。
  • インストルメンテーション テスト: インストルメンテーション テストも実行することをおすすめします。つまり、デバイス上で直接実行されるテストです。
  • 継続的インテグレーション: 継続的インテグレーションを使用すると、テストをデプロイ パイプラインに統合できます。
  • さまざまな画面サイズをテストする: ユーザーが利用できるデバイスは多岐にわたるため、さまざまな画面サイズでテストする必要があります。
  • Espresso: ビューベースの UI を対象としていますが、Espresso の知識は Compose テストのいくつかの側面で役立ちます。