mcpField
C
clicking-and-scrolling

clicking-and-scrolling

github.com/skydovesTesting & quality33962 installsUpdated 22w ago
Repository

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

  1. 01Click and scroll semantics nodes
  2. 02Scroll by index, key, or matcher
  3. 03Request focus on a node
  4. 04Invoke custom semantics actions
  5. 05Click links inside text

Use cases

  1. 01Reveal off-screen content before assertions
  2. 02Find a specific LazyColumn item
  3. 03Test custom accessibility actions
  4. 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-scrolling

After installing, type/clicking-and-scrollingin Claude Code to confirm it loaded.

Details

From the project README

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 / LazyRow to a specific index, key, or matched item.
  • The test must trigger a focus change or invoke an AccessibilityAction<T> declared via Modifier.semantics { … }.
  • The test must click a LinkAnnotation inside a Text.

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.md and ../../debug/printing-the-semantics-tree/SKILL.md.

Prerequisites

  • androidx.compose.ui:ui-test-junit4 (or androidx.compose.ui:ui-test for runComposeUiTest) 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 / LazyRow do; a plain Modifier.verticalScroll does not.
  • For performFirstLinkClick: the Text must use an AnnotatedString carrying a LinkAnnotation.

Workflow

  1. Pick the right receiver node. This is the most common failure mode. performClick / performScrollTo / performFirstLinkClick run on a content node; performScrollToIndex / performScrollToKey / performScrollToNode run on the scrollable container. Confirm with ../../debug/printing-the-semantics-tree/SKILL.md if unsure.

  2. Click a node — performClick(). On Android (both device tests and Robolectric host tests) it delegates to performTouchInput { click() } via the Android actual (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()
  1. Reveal a content node before asserting — performScrollTo(). Walks up the tree to the closest parent carrying SemanticsActions.ScrollBy and scrolls by the smallest delta needed to put the node fully in the viewport (Actions.kt:78-87). Throws AssertionError if no scroll parent exists.
rule.onNodeWithTag(LastItemTag).performScrollTo().assertIsDisplayed()
  1. Scroll a lazy container to an index — performScrollToIndex(index). Requires SemanticsActions.ScrollToIndex on the receiver node (Actions.kt:159-172). The receiver MUST be the container (the LazyColumn itself), not an item.
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(42)
  1. Scroll to a keyed item — performScrollToKey(key). Requires both IndexForKey and ScrollToIndex (Actions.kt:188-203). Matches the key = { … } parameter of LazyColumn / LazyRow items.
rule.onNode(hasScrollToKeyAction()).performScrollToKey("user-7")
  1. Scroll until a matcher matches — performScrollToNode(matcher). Walks the lazy container viewport-by-viewport from start to end. For non-lazy scrollables, falls back to performScrollTo once 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"))
  1. Move focus to a node — requestFocus(). Sugar for performSemanticsAction(SemanticsActions.RequestFocus) (Actions.kt:600-601). Required before performIndirectPointerInput, and useful for BasicTextField setups that bypass performTextInput's auto-focus path.
rule.onNodeWithTag(InputTag).requestFocus()
  1. 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 via Modifier.semantics { … }; otherwise an AssertionError is 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) }
  1. Click a LinkAnnotation inside Text — 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, and performScrollToNode on the scrollable container, not on an item — the semantics actions live on the container. Otherwise AssertionError: the node is missing [ScrollToIndex].
  • MUST call performScrollTo on a content node, not on the container — performScrollTo walks 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). Reading state directly from the test thread races with the recomposer.
  • MUST NOT call performScrollTo on a LazyColumn item — the item probably is not even composed. Use performScrollToIndex / performScrollToKey / performScrollToNode on the container.
  • MUST NOT rely on performClick for 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 / performScrollToNode call targets the container — not an item — and a tag or hasScrollToIndexAction() matcher selects it.
  • No performClick is used to simulate hover, right-click, or scroll wheel.
  • Every performSemanticsAction call references a SemanticsPropertyKey that the production code installs via Modifier.semantics { … }.
  • State assertions after an action read state inside rule.runOnIdle { … }.
  • ./gradlew :app:connectedDebugAndroidTest (or :app:testDebugUnitTest for Robolectric) passes for the test under change.

References

Comments

Markdown supported