Click, Type, Scroll, and Test User Interactions in Compose

Quick answer: Use the highest-level Compose test action that matches the behavior:
performClick()for an accessible click,performTextInput()orperformTextReplacement()for editor input, and semantic scroll actions for lists. Assert the visible result after each meaningful interaction. Reach forperformTouchInputonly when the physical gesture itself—such as a swipe, drag, or off-center tap—is the contract you need to prove.
A strong interaction test is a short user story: find the meaningful control, perform an action, and verify the resulting UI or emitted event. Compose test actions work through the semantics tree, so they test the interface users and accessibility services encounter instead of reaching into private state. The official testing APIs guide groups this model into finders, assertions, and actions.
For the setup and matcher basics, start with Jetpack Compose UI Testing: Setup and First Test and Find Nodes and Assert UI with Compose Test APIs.
Pick the action that matches the contract
| User behavior | Prefer | Test result to assert |
|---|---|---|
| Tap a button, card, or icon action | performClick() | The new screen, message, state, or callback result. |
| Enter more text | performTextInput("…") | The rendered text or enabled submit state. |
| Replace or clear an edit field | performTextReplacement("…") / performTextClearance() | The new field value and any validation. |
| Submit from the keyboard | performImeAction() | The same outcome as the visible submit action. |
| Reveal a known lazy-list item | performScrollToKey, performScrollToIndex, or performScrollToNode | The target content is displayed. |
| Verify a physical swipe, drag, or tap position | performTouchInput { … } | The gesture-specific visual or state outcome. |
Do not use a low-level swipe to test a regular button. performClick() proves that the element exposes a click action and executes it; it is clearer and less fragile. The Compose testing reference lists the perform… actions and their semantic requirements.
Test a semantic click
Use a visible label or content description that is already meaningful to users. Then assert the result of clicking—not simply that the handler ran.
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class CheckoutTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun placeOrder_showsConfirmation() {
composeTestRule.setContent {
CheckoutScreen(order = previewOrder)
}
composeTestRule
.onNodeWithContentDescription("Place order")
.performClick()
composeTestRule
.onNodeWithText("Order placed")
.assertIsDisplayed()
}
}If the control appears several times, scope the finder with the techniques in the node-selection article instead of selecting the first duplicate label. A test that clicks the wrong Remove button can still pass while hiding a real regression.
Type, replace, and clear text deliberately
performTextInput() sends text much like the IME and appends it to existing editor content. Use it for an empty field or when append behavior is the point of the test. performTextReplacement() clears all existing text and inserts the supplied value; performTextClearance() only clears it. The API reference documents these operations as IME-like input.
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
val queryField = composeTestRule.onNodeWithTag("search-query")
queryField.performTextInput("compose")
queryField.assertTextEquals("compose")
queryField.performTextReplacement("compose testing")
queryField.assertTextEquals("compose testing")
queryField.performTextClearance()
queryField.assertTextEquals("")Put the tag on the actual TextField semantics node when a label is ambiguous or the field has no visible label. Do not add a misleading content description just to make the test easier. The Jetpack Compose TextField guide covers choosing field state and labels in production UI.
Test the IME action when it matters
An IME action is a distinct user path: search, send, next, or done. performImeAction() requires a focused editor that exposes an IME action in semantics, so it is useful for a screen that should submit from the keyboard.
import androidx.compose.ui.test.performImeAction
composeTestRule
.onNodeWithTag("search-query")
.performTextInput("compose")
composeTestRule
.onNodeWithTag("search-query")
.performImeAction()
composeTestRule
.onNodeWithText("Results for compose")
.assertIsDisplayed()Do not force an IME-action test onto a field that does not provide one. The API throws when the node is not an editor, cannot establish an input connection, or lacks the needed IME semantics. Test the visible submit control instead when that is the product’s only submission path.
Scroll to content using semantics
For a LazyColumn or LazyRow, prefer semantic scrolling over manually repeating swipes. It is deterministic and says why the test scrolls: to reveal a named item, keyed item, or index.
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
composeTestRule
.onNodeWithTag("products-list")
.performScrollToNode(hasText("Tiramisu"))
composeTestRule
.onNodeWithText("Tiramisu")
.assertIsDisplayed()Call performScrollToNode on the scrollable container, not on the item. It searches from the start toward the end for matching content; if no match is found, it leaves the container at the end and throws an assertion error. This is why the matcher should identify a real, expected item. The scroll action reference details those rules.
When the list has stable keys, a key makes the contract even more explicit:
import androidx.compose.ui.test.performScrollToKey
composeTestRule
.onNodeWithTag("products-list")
.performScrollToKey("product-tiramisu")The production LazyColumn must use the same stable key, for example items(products, key = { it.id }). performScrollToIndex is useful when the item position itself is the requirement; otherwise a key or semantic matcher better survives sorting and filtering. To test a normal scrollable Column, call performScrollTo() on the target content node, which scrolls its nearest scroll parent just enough to reveal it.
Use touch injection for gesture-specific behavior
performTouchInput sends a physical touch sequence to the selected node. Use it for controls whose behavior depends on motion, direction, position, or event timing—not as a replacement for semantic actions.
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
composeTestRule
.onNodeWithTag("dismissible-message")
.performTouchInput {
swipeLeft()
}
composeTestRule
.onNodeWithText("Message dismissed")
.assertIsDisplayed()The touch-injection API batches the events described in its block before they take effect, which avoids injecting later events into a moving target. It also supports lower-level down, moveTo, up, and event-time control for a custom drag. The touch injection reference shows both complete gestures and custom sequences.
Keep a non-gesture route for essential actions. A swipe-to-dismiss item still needs an accessible visible action or custom accessibility action. For implementing those controls, see Click, Long Press, Drag, Swipe, and Gesture Detection in Compose.
Let the test framework synchronize
Compose test actions and assertions normally synchronize with Compose idleness. Do not add Thread.sleep() after each click or scroll: it makes tests slow and still does not express what completion means. Assert the rendered outcome, or use a purposeful wait only for work outside Compose’s synchronization model. Animation clocks and asynchronous sources need their own controlled strategy.
Also keep each interaction observable. Do not hide several actions inside one opaque helper that only returns a final Boolean. A test that shows the state after typing, submitting, and scrolling makes failures easier to locate.
Common interaction-test mistakes
Testing only callbacks
Capturing a lambda value can be a useful unit-level check, but it does not prove that a semantic control exists, accepts input, and updates the UI. Use an interaction test for that user-facing contract.
Appending when replacement is intended
performTextInput("new") after an existing value produces appended input. Use performTextReplacement("new") for edit or overwrite scenarios so the assertion describes the intended user edit.
Scrolling the item instead of the list
performScrollToNode, performScrollToKey, and performScrollToIndex belong on the scrollable container. performScrollTo() is the opposite: call it on the content node to ask its closest scroll parent to reveal it.
Using a swipe to test a button
Low-level injection has a place for drag and swipe contracts. For an ordinary action, performClick() is shorter, semantic, and less dependent on coordinate details.
Interaction-test checklist
- Find a semantic node with a durable label, description, role/state matcher, or justified tag.
- Use
performClickfor an action, text APIs for an editor, and semantic scroll actions for content discovery. - Assert the user-visible result after the action.
- Use stable list keys when identity, rather than position, matters.
- Reserve
performTouchInputfor testing actual pointer-gesture behavior. - Avoid sleeps; control time or external asynchronous work only when the behavior requires it.
These tests turn important paths—submit, search, dismiss, and reveal—into fast regression protection without locking the UI to a particular layout implementation.