Compose レイアウトのテスト

UI または画面のテストは、Compose コードの動作が適切かどうかを検証し、開発プロセスの早い段階でエラーを捉えてアプリの品質を向上させるために使用します。

Compose には、要素を検索し、属性を検証して、ユーザー アクションを実行するためのテスト API が用意されています。また、時間操作などの高度な機能も含まれます。

セマンティクス

Compose の UI テストでは、セマンティクスを使用して UI 階層を操作します。セマンティクスは、その名のとおり、UI の一部に意味を与えます。ここで、「UI の一部」(要素)とは、単一のコンポーザブルから画面全体まで、あらゆるものを指します。セマンティクス ツリーは UI 階層に沿って生成、記述されます。

典型的な UI レイアウトと、対応するセマンティクス ツリーにレイアウトがどのようにマッピングされるかを示す図

図 1. 典型的な UI 階層とそのセマンティクス ツリー。

セマンティクス フレームワークは主にユーザー補助機能で使用されるため、テストでは、UI 階層に関するセマンティクスによって公開される情報を利用します。公開する内容と公開範囲はデベロッパーが決定します。

画像とテキストを含むボタン

図 2. アイコンとテキストを含む一般的なボタン。

たとえば、アイコンとテキスト要素で構成されるこのようなボタンの場合、デフォルトのセマンティクス ツリーには「Like」というテキストラベルしか含まれません。これは、一部のコンポーザブル(Text など)がすでにセマンティクス ツリーに一部のプロパティを公開しているためです。プロパティをセマンティック ツリーに追加するには、Modifier を使用します。

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

設定

このセクションでは、Compose コードをテストできるようにモジュールを設定する方法について説明します。

まず、UI テストを含むモジュールの build.gradle ファイルに次の依存関係を追加します。

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createComposeRule, but not createAndroidComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

このモジュールには、ComposeTestRule と、AndroidComposeTestRule と呼ばれる Android 用の実装が含まれています。このルールを通じて、Compose コンテンツを設定したり、アクティビティにアクセスしたりできます。Compose の一般的な UI テストは次のようになります。

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    // use createAndroidComposeRule<YourActivity>() if you need access to
    // an activity

    @Test
    fun MyTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

テスト API

要素とやり取りする方法は主に 3 つあります。

  • ファインダーを使用すると、1 つまたは複数の要素(セマンティクス ツリー内のノード)を選択して、アサーションを行ったり、アクションを実行したりできます。
  • アサーションは、要素が存在すること、または特定の属性を持つことの確認に使用されます。
  • アクションは、要素に対するクリックやジェスチャーなどのユーザー イベントをシミュレートします。

これらの API の一部は、SemanticsMatcher に対応していて、セマンティクス ツリーの 1 つ以上のノードを参照できます。

ファインダー

onNode では 1 つのノードを、onAllNodes では複数のノードを選択できますが、onNodeWithTextonNodeWithContentDescription などの便利なファインダーを使って一般的な検索を行うこともできます。完全なリストについては、Compose テスト クイック リファレンスをご覧ください。

ノードを 1 つ選択する

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

ノードを複数選択する

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

マージされていないツリーを使用する

一部のノードでは、子のセマンティクス情報がマージされます。たとえば、2 つのテキスト要素を含むボタンでは、ラベルがマージされます。

MyButton {
    Text("Hello")
    Text("World")
}

テストから、printToLog() を使用してセマンティクス ツリーを表示できます。

composeTestRule.onRoot().printToLog("TAG")

このコードでは、次の出力が表示されます。

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

マージされていないツリーのノードと一致させる必要がある場合は、useUnmergedTreetrue に設定します。

composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")

このコードでは、次の出力が表示されます。

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = '[Hello]'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = '[World]'

useUnmergedTree パラメータはすべてのファインダーで使用できます。たとえば onNodeWithText ファインダーでは次のように使用されます。

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

アサーション

1 つ以上のマッチャーを使用して、ファインダーで返される SemanticsNodeInteraction に対して assert() を呼び出すことで、アサーションを確認します。

// Single matcher:
composeTestRule
    .onNode(matcher)
    .assert(hasText("Button")) // hasText is a SemanticsMatcher

// Multiple matchers can use and / or
composeTestRule
    .onNode(matcher).assert(hasText("Button") or hasText("Button2"))

一般的なアサーションには、assertExistsassertIsDisplayedassertTextEquals などの便利な関数も使用できます。完全なリストについては、Compose テスト クイック リファレンスをご覧ください。

ノードのコレクションでアサーションを確認する関数もあります。

// Check number of matched nodes
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())

アクション

ノードにアクションを挿入するには、perform…() 関数を呼び出します。

composeTestRule.onNode(...).performClick()

アクションの例を次に示します。

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

完全なリストについては、Compose テスト クイック リファレンスをご覧ください。

マッチャー

このセクションでは、Compose コードのテストに使用できる一部のマッチャーについて説明します。

階層マッチャー

階層マッチャーを使用すると、セマンティクス ツリーを上下に移動して、シンプルなマッチングを実施できます。

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

こうしたマッチャーの例を次に示します。

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

セレクタ

テストを作成する別の方法として、セレクタを使用する方法があります。これにより、一部のテストが読みやすくなります。

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

完全なリストについては、Compose テスト クイック リファレンスをご覧ください。

同期

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 がまだビジー状態である時点で、アニメーションの正確なスクリーンショットを撮る時間を制御できます。自動同期を無効にするには、mainClockautoAdvance プロパティを 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 状態でのみ機能します)。Android の測定または描画(つまり、Compose の外部の測定または描画)に依存する条件では、waitUntil() のようなより一般的なコンセプトを採用する必要があります。

composeTestRule.waitUntil(timeoutMs) { condition }

一般的なパターン

このセクションでは、Compose テストでよく見られる一般的なアプローチについて説明します。

単独でのテスト

ComposeTestRule を使用すると、任意のコンポーザブル(アプリケーション全体、単一の画面、小さな要素など)を表示するアクティビティを開始できます。また、コンポーザブルが正しくカプセル化されているか、独立して動作するかを確認することをおすすめします。これにより、UI テストをより簡単かつ集中的に行うことができます。

これは、単体 UI テストのみを作成するということではありません。UI テストでは、UI の大きな部分にスコープ設定することも非常に重要です。

カスタム セマンティクス プロパティ

テストに情報を公開するカスタム セマンティクス プロパティを作成できます。作成するには、新しい SemanticsPropertyKey を定義し、SemanticsPropertyReceiver を使用して利用可能にします。

// Creates a Semantics property of type boolean
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

そうすると、semantics 修飾子でこのプロパティを使用できるようになります。

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

テストでは、SemanticsMatcher.expectValue を使用してプロパティの値をアサートできます。

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

デバッグ

テストで問題を解決する主な方法は、セマンティクス ツリーを確認することです。テストの任意の時点で findRoot().printToLog() を呼び出すことで、ツリーを出力できます。この関数は次のようなログを出力します。

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hi'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'There'

こうしたログには、バグを追跡するための貴重な情報が含まれています。

Espresso との相互運用

ハイブリッド アプリでは、ビュー階層内の Compose コンポーネントと Compose コンポーザブル内のビューを(AndroidView コンポーザブルを介して)見つけることができます。

どちらのタイプも、マッチングするために特別な手順は必要ありません。ビューをマッチングするには Espresso の onView を使用し、Compose 要素をマッチングするには ComposeTestRule を使用します。

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

詳細

詳細については、Jetpack Compose テスト Codelab をご覧ください。