Entering 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