clicking-and-scrolling について
Jetpack Compose UI テストの作成者向けに、セマンティクスノードを使ったクリック、スクロール、フォーカス、アクセシビリティ操作を説明するスキルです。Lazy コンテナの対象指定やテキスト内リンクのクリックも扱い、操作先の誤りを防ぎます。
主な機能
- 01セマンティクスノードのクリックとスクロール
- 02インデックス、キー、条件でスクロール
- 03ノードへのフォーカス要求
- 04カスタムセマンティクス操作の実行
- 05テキスト内リンクのクリック
活用シーン
- 01画面外の要素を表示してからアサート
- 02LazyColumn の特定項目を検索
- 03カスタムアクセシビリティ操作のテスト
- 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を使用していること。
ワークフロー
-
適切な受け取り先ノードを選びます。 これは最もよくある失敗の原因です。
performClick/performScrollTo/performFirstLinkClickはコンテンツノードで実行します。performScrollToIndex/performScrollToKey/performScrollToNodeはスクロール可能なコンテナーで実行します。判断に迷った場合は、../../debug/printing-the-semantics-tree/SKILL.mdで確認してください。 -
ノードをクリックします —
performClick()。Android(デバイステストと Robolectric ホストテストの両方)では、Android のactualを介してperformTouchInput { click() }に委譲されます(Actions.android.kt:22-24)。マウスクリックの処理は JetBrains の Compose Multiplatform デスクトップフォークにあります。共通 API は Actions.kt:49-62 です。
rule.onNodeWithTag(SubmitButtonTag).performClick()
- アサーションの前にコンテンツノードを表示します —
performScrollTo()。ツリーを上にたどり、SemanticsActions.ScrollByを持つ最も近い親まで移動して、ノード全体がビューポートに収まるために必要な最小限の量だけスクロールします(Actions.kt:78-87)。スクロール可能な親がない場合はAssertionErrorがスローされます。
rule.onNodeWithTag(LastItemTag).performScrollTo().assertIsDisplayed()
- 遅延コンテナーを指定したインデックスまでスクロールします —
performScrollToIndex(index)。受け取り先ノードにSemanticsActions.ScrollToIndexが必要です(Actions.kt:159-172)。受け取り先は項目ではなく、コンテナー(LazyColumn自体)でなければなりません。
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(42)
- キーを指定して項目までスクロールします —
performScrollToKey(key)。IndexForKeyとScrollToIndexの両方が必要です(Actions.kt:188-203)。LazyColumn/LazyRowの項目にあるkey = { … }パラメーターと照合します。
rule.onNode(hasScrollToKeyAction()).performScrollToKey("user-7")
- マッチャーに一致するまでスクロールします —
performScrollToNode(matcher)。遅延コンテナーのビューポートを先頭から末尾まで移動します。遅延スクロール可能コンテナー以外では、ノードが生成された時点でperformScrollToにフォールバックします(Actions.kt:233-268)。一致するものがないままコンテンツの末尾に達すると、例外がスローされます。
rule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("End of feed"))
- ノードにフォーカスを移します —
requestFocus()。performSemanticsAction(SemanticsActions.RequestFocus)のシンタックスシュガーです(Actions.kt:600-601)。performIndirectPointerInputの前に必要です。また、performTextInputの自動フォーカス処理を迂回するBasicTextFieldのセットアップでも役立ちます。
rule.onNodeWithTag(InputTag).requestFocus()
- カスタムの
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) }
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