About clicking-and-scrolling
A skill for Jetpack Compose UI test authors covering clicks, scrolling, focus, and accessibility actions through semantics nodes. It also explains targeting lazy containers and clicking text links to avoid applying actions to the wrong node.
Key features
- 01Click and scroll semantics nodes
- 02Scroll by index, key, or matcher
- 03Request focus on a node
- 04Invoke custom semantics actions
- 05Click links inside text
Use cases
- 01Reveal off-screen content before assertions
- 02Find a specific LazyColumn item
- 03Test custom accessibility actions
- 04Verify link clicks in text
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill clicking-and-scrollingAfter installing, type/clicking-and-scrollingin Claude Code to confirm it loaded.
Details
From the project READMEClicking and Scrolling — Drive the UI Without a Gesture Builder
Compose's high-level actions resolve to platform-appropriate primitives or to semantics actions that the composable already exposes. Use them whenever the test does not need pixel-level control over the gesture. Reach for performTouchInput { … } (see ../injecting-touch-gestures/SKILL.md) only when the high-level action cannot express the intent.
When to use this skill
- The developer asks "how do I click a Button / IconButton / clickable Box from a test".
- The test must reveal a node before asserting on it (e.g. an item that is currently below the fold).
- A test must scroll a
LazyColumn/LazyRowto a specific index, key, or matched item. - The test must trigger a focus change or invoke an
AccessibilityAction<T>declared viaModifier.semantics { … }. - The test must click a
LinkAnnotationinside aText.
When NOT to use this skill
- The test needs partial gestures, multi-touch, velocity-controlled flings, or split touch sequences across recomposition. Use
../injecting-touch-gestures/SKILL.md. - The test exercises a hover / scroll wheel / right-click flow. Use
../injecting-mouse-and-keyboard/SKILL.md. - The test enters text or fires an IME action. Use
../entering-text/SKILL.md. - A node lookup keeps failing. Verify the matcher first via
../../finders/finding-nodes-by-tag-text-content/SKILL.mdand../../debug/printing-the-semantics-tree/SKILL.md.
Prerequisites
androidx.compose.ui:ui-test-junit4(orandroidx.compose.ui:ui-testforrunComposeUiTest) configured per../../setup/configuring-test-dependencies/SKILL.md.- The target nodes carry
Modifier.testTag("…")from production source — skydoves hot take #1. - For
performScrollToIndex/performScrollToKey: the container must implement the relevant semantics actions.LazyColumn/LazyRowdo; a plainModifier.verticalScrolldoes not. - For
performFirstLinkClick: theTextmust use anAnnotatedStringcarrying aLinkAnnotation.
Workflow
-
Pick the right receiver node. This is the most common failure mode.
performClick/performScrollTo/performFirstLinkClickrun on a content node;performScrollToIndex/performScrollToKey/performScrollToNoderun on the scrollable container. Confirm with../../debug/printing-the-semantics-tree/SKILL.mdif unsure. -
Click a node —
performClick(). On Android (both device tests and Robolectric host tests) it delegates toperformTouchInput { click() }via the Androidactual(Actions.android.kt:22-24). The mouse-click path lives in JetBrains' Compose Multiplatform desktop fork. Common API at Actions.kt:49-62.
rule.onNodeWithTag(SubmitButtonTag).performClick()
- Reveal a content node before asserting —
performScrollTo(). Walks up the tree to the closest parent carryingSemanticsActions.ScrollByand scrolls by the smallest delta needed to put the node fully in the viewport (Actions.kt:78-87). ThrowsAssertionErrorif no scroll parent exists.
rule.onNodeWithTag(LastItemTag).performScrollTo().assertIsDisplayed()
- Scroll a lazy container to an index —
performScrollToIndex(index). RequiresSemanticsActions.ScrollToIndexon the receiver node (Actions.kt:159-172). The receiver MUST be the container (theLazyColumnitself), not an item.
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(42)
- Scroll to a keyed item —
performScrollToKey(key). Requires bothIndexForKeyandScrollToIndex(Actions.kt:188-203). Matches thekey = { … }parameter ofLazyColumn/LazyRowitems.
rule.onNode(hasScrollToKeyAction()).performScrollToKey("user-7")
- Scroll until a matcher matches —
performScrollToNode(matcher). Walks the lazy container viewport-by-viewport from start to end. For non-lazy scrollables, falls back toperformScrollToonce the node materializes (Actions.kt:233-268). Throws when end-of-content is reached without a match.
rule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("End of feed"))
- Move focus to a node —
requestFocus(). Sugar forperformSemanticsAction(SemanticsActions.RequestFocus)(Actions.kt:600-601). Required beforeperformIndirectPointerInput, and useful forBasicTextFieldsetups that bypassperformTextInput's auto-focus path.
rule.onNodeWithTag(InputTag).requestFocus()
- Invoke a custom
AccessibilityAction<T>—performSemanticsAction(key, invocation)for parameterized actions,performSemanticsAction(key)for nullary ones (Actions.kt:631-672). The action MUST be declared on the node viaModifier.semantics { … }; otherwise anAssertionErroris thrown.
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) }
- Click a
LinkAnnotationinsideText—performFirstLinkClick(predicate)(Actions.kt:777-804). The default predicate{ true }clicks the first link. Throws when the receiver has no text or when no link satisfies the predicate.
rule.onNodeWithTag(BodyTag).performFirstLinkClick { it.item is LinkAnnotation.Url }
Patterns
Pattern: Scrolling a LazyColumn — picking the right receiver
// 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()
The matcher hasScrollToIndexAction() is defined in Filters.kt and identifies any container exposing SemanticsActions.ScrollToIndex. Tag the container too if multiple lazy lists exist on screen.
Pattern: Revealing a non-lazy item before asserting
// 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 scans up to the closest hasScrollAction() parent and scrolls by the smallest delta needed (Actions.kt:95-141). For lazy lists, prefer performScrollToNode since the target item may not yet be composed.
Pattern: performScrollToNode for arbitrary content
// RIGHT
rule.onNode(hasScrollToNodeAction()).performScrollToNode(
hasText("Privacy Policy", substring = true)
)
The matcher hasScrollToNodeAction() accepts both lazy and non-lazy scrollables (Filters.kt). For lazy containers, performScrollToNode rewinds to index 0 first and walks viewport-sized steps until the matcher hits.
Pattern: Triggering a custom semantics action
Production:
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:
@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) }
}
This is the cleanest way to test logic that does not have a built-in action like OnClick — no need to fabricate touch coordinates or to depend on the layout being clickable.
Pattern: Clicking the first link in a Text
// 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 first asserts the node has text, collects every LinkAnnotation in the AnnotatedString, picks the first that satisfies the predicate, then dispatches OnClick on the corresponding link child (Actions.kt:777-804).
Pattern: requestFocus before indirect pointer input
// 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
}
The public performIndirectPointerInput extension hangs off SemanticsNodeInteractionsProvider (Actions.kt:862) — i.e. the rule (or ComposeUiTest) itself. The same-name overload on SemanticsNodeInteraction is internal (Actions.kt:942), so rule.onRoot().performIndirectPointerInput(...) does NOT compile from consumer code.
Indirect pointer input dispatches through the focus path, so an explicit requestFocus is mandatory (Actions.kt:807-875). For ordinary touch tests, focus is not required.
Mandatory rules
- MUST call
performScrollToIndex,performScrollToKey, andperformScrollToNodeon the scrollable container, not on an item — the semantics actions live on the container. OtherwiseAssertionError: the node is missing [ScrollToIndex]. - MUST call
performScrollToon a content node, not on the container —performScrollTowalks up to find the scroll parent. - MUST match nodes by
Modifier.testTag("…")whose value is a constant defined in production source — skydoves hot take #1. Text and content-description finders are i18n-fragile. - MUST route any state mutation that follows an action through
runOnIdle { … }(skydoves hot take #5). Readingstatedirectly from the test thread races with the recomposer. - MUST NOT call
performScrollToon aLazyColumnitem — the item probably is not even composed. UseperformScrollToIndex/performScrollToKey/performScrollToNodeon the container. - MUST NOT rely on
performClickfor hover, right-click, or wheel scroll. Use the modality-specific scope from../injecting-mouse-and-keyboard/SKILL.md. - PREFERRED: select the scroll container via
hasScrollAction()/hasScrollToIndexAction()/hasScrollToKeyAction()/hasScrollToNodeAction()(Filters.kt) when there is a single such container on screen, instead of adding a redundant test tag to it.
Verification
- Every
performScrollToIndex/performScrollToKey/performScrollToNodecall targets the container — not an item — and a tag orhasScrollToIndexAction()matcher selects it. - No
performClickis used to simulate hover, right-click, or scroll wheel. - Every
performSemanticsActioncall references aSemanticsPropertyKeythat the production code installs viaModifier.semantics { … }. - State assertions after an action read state inside
rule.runOnIdle { … }. -
./gradlew :app:connectedDebugAndroidTest(or:app:testDebugUnitTestfor Robolectric) passes for the test under change.
References
- Compose testing overview: https://developer.android.com/develop/ui/compose/testing
- Compose testing cheat sheet: https://developer.android.com/develop/ui/compose/testing-cheatsheet
- Semantics in 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— canonicalperformScrollToIndex/performScrollToKeypatterns.compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt—performClick,performTouchInput { longClick() }.- skydoves — compose-performance-skills: https://github.com/skydoves/compose-performance-skills