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 for createAndroidComposeRule<YourActivity>():
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

このモジュールには、ComposeTestRule と、AndroidComposeTestRule という Android 用の実装が含まれています。このルールを使用して、Compose コンテンツを設定したり、アクティビティにアクセスしたりできます。ルールは、ファクトリ関数 createComposeRule、またはアクティビティにアクセスする必要がある場合の createAndroidComposeRule を使用して作成されます。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 }

また、次のいずれかの waitUntil ヘルパーを使用することもできます。

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

一般的なパターン

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

単独でのテスト

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

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

独自のコンテンツの設定後にアクティビティとリソースにアクセスする

多くの場合、composeTestRule.setContent を使用してテスト対象のコンテンツを設定する必要があり、アクティビティ リソースにアクセスする必要もあります。たとえば、表示されるテキストが文字列リソースに一致することをアサートします。ただし、アクティビティがすでに setContent を呼び出している場合、createAndroidComposeRule() で作成したルールに対してまた呼び出すことはできません。

これを実現するための一般的なパターンは、空のアクティビティ(ComponentActivity など)を使用して AndroidComposeTestRule を作成することです。

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, /*...*/)
            }
        }
        val continueLabel = composeTestRule.activity.getString(R.string.next)
        composeTestRule.onNodeWithText(continueLabel).performClick()
    }
}

アプリの AndroidManifest.xml ファイルに ComponentActivity を追加する必要があるのでご注意ください。そのためには、次の依存関係をモジュールに追加します。

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

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

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

// Creates a Semantics property of type Long
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()

状態の復元を検証する

アクティビティまたはプロセスを再作成する場合、Compose 要素の状態が正しく復元されていることを検証してください。StateRestorationTester クラスを使用すると、アクティビティの再作成に依存せずに、こうしたチェックを行えます。

このクラスを使用すると、コンポーザブルの再作成をシミュレートできます。特に、rememberSaveable の実装を検証するのに便利です。


class MyStateRestorationTests {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)

        restorationTester.setContent { MainScreen() }

        // TODO: Run actions that modify the state

        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()

        // TODO: Verify that state has been correctly restored.
    }
}

さまざまなデバイス設定をテストする

Android アプリは、ウィンドウ サイズ、ロケール、フォントサイズ、ダークモードとライトモードなど、さまざまな状況の変化に適応する必要があります。これらの条件のほとんどは、ユーザーが制御するデバイスレベルの値から導出され、現在の Configuration インスタンスで公開されます。テストではデバイスレベルのプロパティを構成する必要があるため、テスト内でさまざまな構成を直接テストするのは困難です。

DeviceConfigurationOverride は、テスト対象の @Composable コンテンツに対して、さまざまなデバイス構成をローカライズされた方法でシミュレートできるテスト専用の API です。

DeviceConfigurationOverride のコンパニオン オブジェクトには、デバイスレベルの設定プロパティをオーバーライドする次の拡張関数があります。

特定のオーバーライドを適用するには、DeviceConfigurationOverride() トップレベル関数の呼び出しでテスト対象コンテンツをラップし、パラメータとして適用するオーバーライドを渡します。

たとえば、次のコードは、DeviceConfigurationOverride.ForcedSize() オーバーライドを適用してローカルで密度を変更し、テストを実行するデバイスがそのウィンドウ サイズを直接サポートしていない場合でも、MyScreen コンポーザブルを大きな横向きウィンドウでレンダリングします。

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.ForcedSize(DpSize(1280.dp, 800.dp))
    ) {
        MyScreen() // will be rendered in the space for 1280dp by 800dp without clipping
    }
}

複数のオーバーライドをまとめて適用するには、DeviceConfigurationOverride.then() を使用します。

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.FontScale(1.5f) then
            DeviceConfigurationOverride.FontWeightAdjustment(200)
    ) {
        Text(text = "text with increased scale and weight")
    }
}

デバッグ

テストで問題を解決する主な方法は、セマンティクス ツリーを確認することです。テストの任意の時点で composeTestRule.onRoot().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()))
}

UiAutomator との相互運用

デフォルトでは、使い勝手のよい記述子(表示されるテキストやコンテンツの説明など)によってのみ、UiAutomator からコンポーザブルにアクセスできます。Modifier.testTag を使用する任意のコンポーザブルにアクセスするには、そのコンポーザブルのサブツリーに対してセマンティック プロパティ testTagsAsResourceId を有効にする必要があります。この動作を有効にすると、スクロール可能なコンポーザブル(LazyColumn など)のように、一意のハンドルがないコンポーザブルに役立ちます。

コンポーザブルの階層では、上位で 1 度有効にすれば、Modifier.testTag を持つすべてのネストされたコンポーザブルに UiAutomator からアクセスできるようになります。

Scaffold(
    // Enables for all composables in the hierarchy.
    modifier = Modifier.semantics {
        testTagsAsResourceId = true
    }
){
    // Modifier.testTag is accessible from UiAutomator for composables nested here.
    LazyColumn(
        modifier = Modifier.testTag("myLazyColumn")
    ){
        // content
    }
}

Modifier.testTag(tag) を持つ任意のコンポーザブルにアクセスするには、By.res(resourceName)resourceName と同じ tag を使用します。

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

詳細

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

サンプル