About entering-text
A skill for using Compose UI test semantics actions to enter, replace, and clear text, set selections, and invoke IME actions. It is intended for developers testing TextField and other editable composables, with guidance on prerequisites and action differences.
Key features
- 01Enter text at the cursor
- 02Replace or clear field contents
- 03Set text selection
- 04Invoke IME actions
- 05Locate fields by test tag
Use cases
- 01Test text field input and replacement behavior
- 02Exercise IME actions such as Next or Done
- 03Verify edits based on cursor and selection
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill entering-textAfter installing, type/entering-textin Claude Code to confirm it loaded.
Details
From the project READMEEntering Text — performTextInput, performTextReplacement, performImeAction
Compose text fields expose semantics actions for IME-style edits. The test API wraps those actions so a single call types text, replaces text, clears text, sets a selection, or fires the IME action. Every entry point auto-focuses the node when needed and asserts that the node is enabled and editable, surfacing precise errors when one of those preconditions fails.
When to use this skill
- The test types into
TextField,OutlinedTextField,BasicTextField, or any composable that exposes theInsertTextAtCursor/SetTextsemantics actions. - The test fires an IME action —
ImeAction.Nextto advance focus,ImeAction.Doneto dismiss the keyboard,ImeAction.Search/Send/Goto trigger a form submit. - The test must clear an existing value, or replace the full value with new text.
- The test asserts cursor or selection state with
performTextInputSelection(TextRange(...)).
When NOT to use this skill
- The test sends an arbitrary key shortcut into a focused field (e.g.
Ctrl+A,Tab, arrow keys). Use../injecting-mouse-and-keyboard/SKILL.md. - The test only checks the displayed text without typing — assert via
../../assertions/asserting-node-state-and-text/SKILL.md. - A finder cannot locate the text field. Verify the matcher first via
../../finders/finding-nodes-by-tag-text-content/SKILL.md. - The test asserts state restoration of the typed text after rotation. See
../../setup/configuring-test-dependencies/SKILL.mdandStateRestorationTester.
Prerequisites
androidx.compose.ui:ui-test-junit4(orui-testforrunComposeUiTest) configured per../../setup/configuring-test-dependencies/SKILL.md.- The
TextFieldcarriesModifier.testTag("…")from production code so the test selects it deterministically. - The field is
enabled = true.performTextInputcallsassert(isEnabled())before doing anything (TextActions.kt:125). - For
performImeAction: the field declares bothImeAction != ImeAction.Default(viaKeyboardOptions(imeAction = …)) and anOnImeActionhandler (viaKeyboardActions(...)or default keyboard actions). The action assertion checks both (TextActions.kt:99-100).
Workflow
- Type text at the cursor —
performTextInput(text)(TextActions.kt:37-41). Internally invokesSemanticsActions.InsertTextAtCursorwithAnnotatedString(text). Auto-focuses the node first when it is not focused (getNodeAndFocus— TextActions.kt:119-138). ThrowsAssertionError("Failed to perform text input.")if the node is not enabled, not focusable, or does not advertiseInsertTextAtCursor.
rule.onNodeWithTag(Tag).performTextInput("Hello ")
- Replace the entire text —
performTextReplacement(text)(TextActions.kt:81-84). InvokesSemanticsActions.SetText, which clears existing text and inserts new text. Use this when the field already has a value the test wants to overwrite. MUST NOT be used to "type more characters" — see the WRONG/RIGHT pair below.
rule.onNodeWithTag(Tag).performTextReplacement("New value")
- Clear the text —
performTextClearance()(TextActions.kt:28-30). Sugar forperformTextReplacement(""). The field must be focusable + haveSetText.
rule.onNodeWithTag(Tag).performTextClearance()
- Set the selection / cursor —
performTextInputSelection(selection: TextRange, relativeToOriginalText: Boolean = true)(TextActions.kt:61-72). Maps toSemanticsActions.SetSelection. WhenrelativeToOriginalText = true, indices refer to the original (untransformed) text;falsemakes them refer to the post-VisualTransformationtext.
rule.onNodeWithTag(Tag).performTextInput("Hello World")
rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(0, 5)) // selects "Hello"
- Fire the IME action —
performImeAction()(TextActions.kt:97-117). Pre-conditions, in order:assert(hasPerformImeAction())— the field declaresOnImeAction.assert(!hasImeAction(ImeAction.Default))— the action is something other than the default.tryPerformAccessibilityChecks().- Auto-focus via
getNodeAndFocus(requireEditable = false). - Invoke
OnImeActionand require it returnedtrue; otherwise throwAssertionError("Failed to perform IME action, handler returned false.").
rule.onNodeWithTag(InitialFieldTag).performImeAction()
- Pair with
assertTextEqualsto confirm the expected post-state. The merged-tree default applies to text assertions exactly as to finders — see../../assertions/asserting-node-state-and-text/SKILL.md.
Patterns
Pattern: Append (performTextInput) vs replace (performTextReplacement)
The most common gotcha — the two APIs look interchangeable but compose differently.
// WRONG
rule.onNodeWithTag(Tag).performTextInput("hello")
rule.onNodeWithTag(Tag).performTextInput("world")
rule.onNodeWithTag(Tag).assertTextEquals("world")
// WRONG because: performTextInput maps to InsertTextAtCursor, not SetText. The cursor
// remains at the end of "hello" after the first call, so the second call inserts
// "world" there. Final value is "helloworld" — assertion fails.
// RIGHT
rule.onNodeWithTag(Tag).performTextInput("hello")
rule.onNodeWithTag(Tag).performTextReplacement("world")
rule.onNodeWithTag(Tag).assertTextEquals("world")
performTextReplacement calls SemanticsActions.SetText (TextActions.kt:83), which clears existing characters before inserting the new text — equivalent to the IME's "set composing region to whole text and replace" path. Use it any time the second call should overwrite, not append.
Pattern: Canonical insert + assert
From BasicTextFieldTest.kt:171-194:
@Test
fun textFieldState_textChange_updatesState() {
val state = TextFieldState("Hello ", TextRange(Int.MAX_VALUE))
inputMethodInterceptor.setTextFieldTestContent {
BasicTextField(state = state, modifier = Modifier.fillMaxSize().testTag(Tag))
}
rule.onNodeWithTag(Tag).performTextInput("World!")
rule.runOnIdle { assertThat(state.text.toString()).isEqualTo("Hello World!") }
}
@Test
fun textFieldState_textChange_updatesSemantics() {
val state = TextFieldState("Hello ", TextRange(Int.MAX_VALUE))
inputMethodInterceptor.setTextFieldTestContent {
BasicTextField(state = state, modifier = Modifier.fillMaxSize().testTag(Tag))
}
rule.onNodeWithTag(Tag).performTextInput("World!")
rule.onNodeWithTag(Tag).assertTextEquals("Hello World!")
}
The cursor is positioned at TextRange(Int.MAX_VALUE) (end of text) when constructing TextFieldState. performTextInput("World!") inserts at that cursor — final value "Hello World!". State reads happen inside runOnIdle (skydoves hot take #5).
Pattern: Replace then assert — BasicTextFieldTest.kt:442-466
@Test
fun textField_whenStateObjectChanges_restartsInput() {
val state1 = TextFieldState("Hello")
val state2 = TextFieldState("World")
var toggleState by mutableStateOf(true)
val state by derivedStateOf { if (toggleState) state1 else state2 }
inputMethodInterceptor.setTextFieldTestContent {
BasicTextField(
state = state,
enabled = true,
modifier = Modifier.fillMaxSize().testTag(Tag),
)
}
with(rule.onNodeWithTag(Tag)) {
performTextReplacement("Compose")
assertTextEquals("Compose")
}
toggleState = !toggleState
with(rule.onNodeWithTag(Tag)) {
performTextReplacement("Compose2")
assertTextEquals("Compose2")
}
assertThat(state1.text.toString()).isEqualTo("Compose")
assertThat(state2.text.toString()).isEqualTo("Compose2")
}
performTextReplacement overwrites whatever the field's current value happens to be — making it the right call when state is rotated underneath.
Pattern: Clearing then refilling
rule.onNodeWithTag(Tag).performTextClearance()
rule.onNodeWithTag(Tag).performTextInput("Different")
rule.onNodeWithTag(Tag).assertTextEquals("Different")
performTextClearance is sugar for performTextReplacement("") (TextActions.kt:28-30). After it runs, the cursor is at position 0, so a follow-up performTextInput simply inserts at the start.
Pattern: IME-action focus chain (Next advances focus)
From DefaultKeyboardActionsTest.kt:130-149:
// Show keyboard.
rule.onNodeWithTag(initialTextField).performClick()
inputMethodInterceptor.assertSessionActive()
keyboardController.show()
// Act.
rule.onNodeWithTag(initialTextField).performImeAction()
// Assert.
when (imeAction) {
Next -> {
// Focus moves to the next item.
assertThat(focusState1).isFalse()
assertThat(focusState2).isFalse()
assertThat(focusState3).isTrue()
}
Previous -> {
// Focus moves to the previous item.
assertThat(focusState1).isTrue()
assertThat(focusState2).isFalse()
assertThat(focusState3).isFalse()
}
Done -> {
// No change to focus state.
assertThat(focusState1).isFalse()
assertThat(focusState2).isTrue()
assertThat(focusState3).isFalse()
// Software keyboard is hidden.
keyboardController.assertHidden()
}
}
Production must declare a non-default ImeAction for performImeAction to be valid:
BasicTextField(
state = state,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.testTag(InitialFieldTag),
)
performImeAction asserts !hasImeAction(ImeAction.Default) first (TextActions.kt:100). Forgetting the keyboardOptions parameter yields AssertionError("Failed to perform IME action.").
Pattern: Set selection before replacing a substring
rule.onNodeWithTag(Tag).performTextInput("Hello World")
rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(6, 11)) // select "World"
rule.onNodeWithTag(Tag).performTextInput("Compose") // selection replaced
rule.onNodeWithTag(Tag).assertTextEquals("Hello Compose")
SemanticsActions.SetSelection honours the same path the IME uses, so a follow-up InsertTextAtCursor replaces the active selection with the new text. Pass relativeToOriginalText = false if the field installs a VisualTransformation (e.g. password mask, currency formatter) and the indices were computed against the displayed string.
Pattern: Disabled or non-editable field — clear error
If the field is enabled = false or the matcher resolves to a Text instead of a TextField, performTextInput throws AssertionError("Failed to perform text input."). The chain is:
tryPerformAccessibilityChecks().assert(isEnabled()).assert(hasRequestFocusAction()).assert(hasSetTextAction())andassert(hasInsertTextAtCursorAction())(TextActions.kt:127-129).
When the assertion fires, dump the semantics tree via ../../debug/printing-the-semantics-tree/SKILL.md to confirm the matcher is not selecting a static Text.
Mandatory rules
- MUST use
performTextReplacementto overwrite an existing value. MUST NOT chain twoperformTextInputcalls expecting the second to replace the first — they concatenate at the cursor. - MUST declare a non-default
ImeAction(e.g.KeyboardOptions(imeAction = ImeAction.Next)) before callingperformImeAction. The API asserts!hasImeAction(ImeAction.Default)(TextActions.kt:100) and throws otherwise. - MUST select the field by
Modifier.testTag(...)whose value is a constant in production code — skydoves hot take #1. - MUST route post-input state assertions through
rule.runOnIdle { … }(skydoves hot take #5). The recomposer needs to settle before readingTextFieldState.text. - MUST NOT call
Thread.sleepto wait for IME events. Compose's idle observation includes the text-input pipeline; the assertion will be ready whenrunOnIdlereturns. - MUST NOT simulate text entry by injecting individual
Keyevents throughperformKeyInput { pressKey(Key.A) }unless the test specifically exercises the hardware-keyboard path. The semantics actions are the canonical IME path. - PREFERRED: keep
performTextInputSelectioncalls explicit aboutrelativeToOriginalText. The default (true) is correct for most cases; flip it only when the field installs aVisualTransformation.
Verification
- Every test that overwrites field content uses
performTextReplacement(...)orperformTextClearance()followed byperformTextInput(...). No back-to-backperformTextInputcalls expecting replacement semantics. - Every
performImeActioncall targets a field that declaresKeyboardOptions(imeAction = ImeAction.Next | Done | Search | …)in production source. - State or text assertions following an input action run inside
rule.runOnIdle { … }. - No
Thread.sleepin the test method. -
./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
- BasicTextField docs: https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/package-summary#BasicTextField(androidx.compose.foundation.text.input.TextFieldState,androidx.compose.ui.Modifier,kotlin.Boolean,kotlin.Boolean,androidx.compose.ui.text.TextStyle,androidx.compose.foundation.text.KeyboardOptions,androidx.compose.foundation.text.KeyboardActions,kotlin.Boolean,kotlin.Int,kotlin.Int,androidx.compose.foundation.text.input.InputTransformation,androidx.compose.ui.graphics.SolidColor,androidx.compose.foundation.text.input.OutputTransformation,androidx.compose.foundation.interaction.MutableInteractionSource,androidx.compose.foundation.text.input.TextFieldDecorator)
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TextActions.kt—performTextClearance(TextActions.kt:28),performTextInput(TextActions.kt:37),performTextInputSelection(TextActions.kt:61),performTextReplacement(TextActions.kt:81),performImeAction(TextActions.kt:97),getNodeAndFocusprecondition chain (TextActions.kt:119-138).compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt—hasPerformImeAction,hasImeAction,hasSetTextAction,hasInsertTextAtCursorAction,isEnabled,hasRequestFocusAction.compose/foundation/foundation/src/androidDeviceTest/.../BasicTextFieldTest.kt— canonicalperformTextInput+assertTextEquals(BasicTextFieldTest.kt:171-194),performTextReplacement(BasicTextFieldTest.kt:442-466).compose/foundation/foundation/src/androidDeviceTest/.../DefaultKeyboardActionsTest.kt—performImeAction+ IME-action focus chain (DefaultKeyboardActionsTest.kt:130-149).- skydoves — compose-performance-skills: https://github.com/skydoves/compose-performance-skills