Clicking 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