mcpField
C
clicking-and-scrolling

clicking-and-scrolling

github.com/skydovesテスト・品質33962 回インストール22 週間前に更新
リポジトリ

clicking-and-scrolling について

Jetpack Compose UI テストの作成者向けに、セマンティクスノードを使ったクリック、スクロール、フォーカス、アクセシビリティ操作を説明するスキルです。Lazy コンテナの対象指定やテキスト内リンクのクリックも扱い、操作先の誤りを防ぎます。

主な機能

  1. 01セマンティクスノードのクリックとスクロール
  2. 02インデックス、キー、条件でスクロール
  3. 03ノードへのフォーカス要求
  4. 04カスタムセマンティクス操作の実行
  5. 05テキスト内リンクのクリック

活用シーン

  1. 01画面外の要素を表示してからアサート
  2. 02LazyColumn の特定項目を検索
  3. 03カスタムアクセシビリティ操作のテスト
  4. 04テキスト内リンクのクリックを検証

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill clicking-and-scrolling

インストール後、Claude Code で/clicking-and-scrollingを入力して読み込まれたか確認します。

詳細

クリックとスクロール — ジェスチャービルダーを使わずに UI を操作する

Compose の高水準アクションは、プラットフォームに適したプリミティブ、またはコンポーザブルがすでに公開しているセマンティクスアクションに解決されます。ジェスチャーをピクセル単位で制御する必要がないテストでは、これらを使用してください。高水準アクションでは意図を表現できない場合に限り、performTouchInput { … }(../injecting-touch-gestures/SKILL.md を参照)を使用してください。

このスキルを使用する場合

  • 「テストから Button/IconButton/clickable な Box をクリックするにはどうすればよいか」と開発者から質問された場合。
  • アサーションの前にノードを表示する必要がある場合(例:現在は画面の下にあるアイテム)。
  • LazyColumn/LazyRow を特定のインデックス、キー、または条件に一致するアイテムまでスクロールする必要がある場合。
  • フォーカスの変更を発生させる場合や、Modifier.semantics { … } で宣言された AccessibilityAction<T> を呼び出す場合。
  • Text 内の LinkAnnotation をクリックする必要がある場合。

このスキルを使用しない場合

  • 部分的なジェスチャー、マルチタッチ、速度を制御したフリング、または再コンポジションをまたぐタッチシーケンスが必要な場合は、../injecting-touch-gestures/SKILL.md を使用してください。
  • ホバー/スクロールホイール/右クリックの操作をテストする場合は、../injecting-mouse-and-keyboard/SKILL.md を使用してください。
  • テキストを入力する場合や IME アクションを発生させる場合は、../entering-text/SKILL.md を使用してください。
  • ノードの検索に繰り返し失敗する場合は、まず ../../finders/finding-nodes-by-tag-text-content/SKILL.md と ../../debug/printing-the-semantics-tree/SKILL.md を参照して、マッチャーを確認してください。

前提条件

  • ../../setup/configuring-test-dependencies/SKILL.md に従って、androidx.compose.ui:ui-test-junit4(または runComposeUiTest 用の androidx.compose.ui:ui-test)が設定されていること。
  • 対象ノードに、本番ソースから Modifier.testTag("…") が設定されていること — skydoves のホットテイク #1。
  • performScrollToIndex/performScrollToKey を使用する場合、コンテナーが該当するセマンティクスアクションを実装していること。LazyColumn/LazyRow は対応していますが、通常の Modifier.verticalScroll は対応していません。
  • performFirstLinkClick を使用する場合、Text が LinkAnnotation を持つ AnnotatedString を使用していること。

ワークフロー

  1. 適切な受け取り先ノードを選びます。 これは最もよくある失敗の原因です。performClick / performScrollTo / performFirstLinkClick はコンテンツノードで実行します。performScrollToIndex / performScrollToKey / performScrollToNode はスクロール可能なコンテナーで実行します。判断に迷った場合は、../../debug/printing-the-semantics-tree/SKILL.md で確認してください。

  2. ノードをクリックします — performClick()。Android(デバイステストと Robolectric ホストテストの両方)では、Android の actual を介して performTouchInput { click() } に委譲されます(Actions.android.kt:22-24)。マウスクリックの処理は JetBrains の Compose Multiplatform デスクトップフォークにあります。共通 API は Actions.kt:49-62 です。

rule.onNodeWithTag(SubmitButtonTag).performClick()
  1. アサーションの前にコンテンツノードを表示します — performScrollTo()。ツリーを上にたどり、SemanticsActions.ScrollBy を持つ最も近い親まで移動して、ノード全体がビューポートに収まるために必要な最小限の量だけスクロールします(Actions.kt:78-87)。スクロール可能な親がない場合は AssertionError がスローされます。
rule.onNodeWithTag(LastItemTag).performScrollTo().assertIsDisplayed()
  1. 遅延コンテナーを指定したインデックスまでスクロールします — performScrollToIndex(index)。受け取り先ノードに SemanticsActions.ScrollToIndex が必要です(Actions.kt:159-172)。受け取り先は項目ではなく、コンテナー(LazyColumn 自体)でなければなりません。
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(42)
  1. キーを指定して項目までスクロールします — performScrollToKey(key)。IndexForKey と ScrollToIndex の両方が必要です(Actions.kt:188-203)。LazyColumn / LazyRow の項目にある key = { … } パラメーターと照合します。
rule.onNode(hasScrollToKeyAction()).performScrollToKey("user-7")
  1. マッチャーに一致するまでスクロールします — performScrollToNode(matcher)。遅延コンテナーのビューポートを先頭から末尾まで移動します。遅延スクロール可能コンテナー以外では、ノードが生成された時点で performScrollTo にフォールバックします(Actions.kt:233-268)。一致するものがないままコンテンツの末尾に達すると、例外がスローされます。
rule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("End of feed"))
  1. ノードにフォーカスを移します — requestFocus()。performSemanticsAction(SemanticsActions.RequestFocus) のシンタックスシュガーです(Actions.kt:600-601)。performIndirectPointerInput の前に必要です。また、performTextInput の自動フォーカス処理を迂回する BasicTextField のセットアップでも役立ちます。
rule.onNodeWithTag(InputTag).requestFocus()
  1. カスタムの AccessibilityAction<T> を呼び出します — パラメーター付きのアクションには performSemanticsAction(key, invocation)、引数のないアクションには performSemanticsAction(key) を使います(Actions.kt:631-672)。アクションは Modifier.semantics { … } を介してノードに宣言されていなければなりません。宣言されていない場合は AssertionError がスローされます。
val MyAction = SemanticsPropertyKey<AccessibilityAction<(Int) -> Boolean>>("MyAction")
// production:
Modifier.semantics { this[MyAction] = AccessibilityAction("Bump") { delta -> bump(delta); true } }
// test:
rule.onNodeWithTag(Tag).performSemanticsAction(MyAction) { it(3) }
  1. Text 内の LinkAnnotation をクリックします — performFirstLinkClick(predicate)(Actions.kt:777-804)。デフォルトの述語 { true } は最初のリンクをクリックします。受け取り先にテキストがない場合、または述語に一致するリンクがない場合は例外がスローされます。
rule.onNodeWithTag(BodyTag).performFirstLinkClick { it.item is LinkAnnotation.Url }

パターン

パターン:LazyColumn のスクロール — 適切な受け取り先の選択
// WRONG
rule.onNodeWithTag("item-7").performScrollToIndex(7)
// WRONG because: performScrollToIndex requires the ScrollToIndex semantics action,
// which lives on the LazyColumn container, not on its items. AssertionError:
// "Failed to scroll to index 7, the node is missing [ScrollToIndex]".
// RIGHT
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(7)
rule.onNodeWithTag("item-7").assertIsDisplayed()

マッチャー hasScrollToIndexAction() は Filters.kt で定義されており、SemanticsActions.ScrollToIndex を公開するすべてのコンテナーを識別します。画面上に複数の遅延リストがある場合は、コンテナーにもタグを付けてください。

パターン:アサーションの前に遅延生成されない項目を表示する
// WRONG
rule.onNodeWithTag(BottomBannerTag).assertIsDisplayed()
// WRONG because: a Column inside Modifier.verticalScroll renders all children, but
// children outside the viewport are clipped — assertIsDisplayed fails because
// the visible bounds intersect the viewport at zero pixels.
// RIGHT
rule.onNodeWithTag(BottomBannerTag).performScrollTo().assertIsDisplayed()

performScrollTo は hasScrollAction() を持つ最も近い親までさかのぼり、必要な最小限の量だけスクロールします(Actions.kt:95-141)。遅延リストでは、対象の項目がまだコンポーズされていない場合があるため、performScrollToNode を優先してください。

パターン:任意のコンテンツに対する performScrollToNode
// RIGHT
rule.onNode(hasScrollToNodeAction()).performScrollToNode(
    hasText("Privacy Policy", substring = true)
)

マッチャー hasScrollToNodeAction() は、遅延スクロール可能コンテナーと遅延生成されないスクロール可能コンテナーの両方を受け付けます(Filters.kt)。遅延コンテナーの場合、performScrollToNode はまずインデックス 0 に戻り、マッチャーに一致するまでビューポート単位で移動します。

パターン:カスタムセマンティクスアクションの実行

本番コード:

val Bump = SemanticsPropertyKey<AccessibilityAction<(Int) -> Boolean>>("Bump")

@Composable
fun Counter(value: Int, onBump: (Int) -> Unit) {
    Box(
        Modifier
            .testTag("counter")
            .semantics {
                this[Bump] = AccessibilityAction("Bump") { delta ->
                    onBump(delta); true
                }
            }
    ) { Text(value.toString()) }
}

テスト:

@Test fun bumpAction_increments() {
    var value by mutableIntStateOf(0)
    rule.setContent { Counter(value) { value += it } }

    rule.onNodeWithTag("counter").performSemanticsAction(Bump) { it(5) }

    rule.runOnIdle { assertEquals(5, value) }
}

これは、OnClick のような組み込みアクションがないロジックをテストする最もすっきりした方法です。タッチ座標を作り出したり、レイアウトがクリック可能であることに依存したりする必要はありません。

パターン:テキスト内の最初のリンクをクリックする
// RIGHT
@Test fun privacyLink_navigates() {
    var clicked = false
    rule.setContent {
        Text(
            buildAnnotatedString {
                append("Read our ")
                withLink(LinkAnnotation.Url("https://example.com/privacy") {
                    clicked = true
                }) { append("Privacy Policy") }
            },
            modifier = Modifier.testTag(BodyTag),
        )
    }

    rule.onNodeWithTag(BodyTag).performFirstLinkClick { it.item is LinkAnnotation.Url }

    rule.runOnIdle { assertTrue(clicked) }
}

performFirstLinkClick は、ノードにテキストがあることを最初にアサートし、AnnotatedString 内のすべての LinkAnnotation を収集して、述語を満たす最初のものを選び、対応するリンクの子に対して OnClick をディスパッチします(Actions.kt:777-804)。

パターン:間接的なポインター入力の前に requestFocus を呼び出す
// RIGHT
rule.onNodeWithTag(SurfaceTag).requestFocus()
rule.performIndirectPointerInput(                  // extension on SemanticsNodeInteractionsProvider, not on a node
    indirectPointerEventPrimaryDirectionalMotionAxis = Vertical,
    inputDeviceSize = IntSize(1000, 1000),
) {
    // events go to the focused tree
}

公開されている performIndirectPointerInput 拡張関数は SemanticsNodeInteractionsProvider、つまりルール(または ComposeUiTest)に紐づいています(Actions.kt:862)。同じ名前の SemanticsNodeInteraction 上のオーバーロードは internal なので(Actions.kt:942)、コンシューマーコードから rule.onRoot().performIndirectPointerInput(...) を呼び出してもコンパイルできません。

間接的なポインター入力はフォーカス経路を通じてディスパッチされるため、明示的な requestFocus が必須です(Actions.kt:807-875)。通常のタッチテストでは、フォーカスは必要ありません。

必須ルール

  • 必須: performScrollToIndex、performScrollToKey、performScrollToNode は項目ではなく、スクロール可能なコンテナーに対して呼び出してください。セマンティクスアクションはコンテナーにあります。そうしないと、AssertionError: the node is missing [ScrollToIndex] が発生します。
  • 必須: performScrollTo はコンテナーではなく、コンテンツノードに対して呼び出してください。performScrollTo はスクロール親を見つけるために上方向にたどります。
  • 必須: 本番ソースで定数として定義された値を持つ Modifier.testTag("…") でノードを照合してください(skydoves の独自見解 #1)。テキストやコンテンツ説明による検索は、i18n の変更に弱いです。
  • 必須: アクションの後に状態を変更する場合は、必ず runOnIdle { … } を通してください(skydoves の独自見解 #5)。テストスレッドから state を直接読み取ると、recomposer と競合します。
  • 禁止: LazyColumn の項目に対して performScrollTo を呼び出さないでください。その項目はまだ合成されていない可能性があります。コンテナーに対して performScrollToIndex/performScrollToKey/performScrollToNode を使用してください。
  • 禁止: ホバー、右クリック、ホイールスクロールの再現に performClick を使用しないでください。../injecting-mouse-and-keyboard/SKILL.md にある入力方式固有のスコープを使用してください。
  • 推奨: 画面上に該当するコンテナーが1つだけの場合は、冗長なテストタグを追加する代わりに、hasScrollAction()/hasScrollToIndexAction()/hasScrollToKeyAction()/hasScrollToNodeAction()(Filters.kt)でスクロールコンテナーを選択してください。

検証

  • すべての performScrollToIndex/performScrollToKey/performScrollToNode の呼び出し対象が項目ではなくコンテナーであり、タグまたは hasScrollToIndexAction() マッチャーで選択されていること。
  • ホバー、右クリック、ホイールスクロールの再現に performClick が使用されていないこと。
  • すべての performSemanticsAction 呼び出しが、本番コードで Modifier.semantics { … } を通じて設定される SemanticsPropertyKey を参照していること。
  • アクション後の状態アサーションで、rule.runOnIdle { … } 内から状態を読み取っていること。
  • 変更対象のテストで ./gradlew :app:connectedDebugAndroidTest(または Robolectric の場合は :app:testDebugUnitTest)が成功すること。

参考資料

  • Compose のテスト概要:https://developer.android.com/develop/ui/compose/testing
  • Compose のテスト用チートシート:https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • Compose のセマンティクス:https://developer.android.com/develop/ui/compose/accessibility/semantics
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt — performClick (Actions.kt:58)、performScrollTo (Actions.kt:78)、performScrollToIndex (Actions.kt:159)、performScrollToKey (Actions.kt:188)、performScrollToNode (Actions.kt:233)、requestFocus (Actions.kt:600)、performSemanticsAction (Actions.kt:631 / Actions.kt:668)、performFirstLinkClick (Actions.kt:777)。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt — hasScrollAction、hasScrollToIndexAction、hasScrollToKeyAction、hasScrollToNodeAction。
  • compose/foundation/foundation/integration-tests/lazy-tests/.../LazyListTest.kt — performScrollToIndex / performScrollToKey の標準的なパターン。
  • compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt — performClick、performTouchInput { longClick() }。
  • skydoves — compose-performance-skills:https://github.com/skydoves/compose-performance-skills

コメント