关于 entering-text
此技能介绍如何在 Compose UI 测试中通过语义操作输入、替换和清除文本、设置选区及触发 IME 操作。它面向测试 TextField 等可编辑组件的开发者,并说明各操作的前置条件与差异。
主要功能
- 01在光标处输入文本
- 02替换或清除字段内容
- 03设置文本选区
- 04触发 IME 操作
- 05通过测试标签定位字段
适用场景
- 01验证文本字段的输入与替换行为
- 02测试 Next 或 Done 等 IME 操作
- 03检查光标和选区对应的文本修改
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill entering-text安装后在 Claude Code 中输入/entering-text确认已加载。
详细介绍
输入文本 — performTextInput、performTextReplacement、performImeAction
Compose 文本字段会公开用于 IME 风格编辑的语义操作。测试 API 对这些操作进行了封装,只需调用一次即可输入文本、替换文本、清除文本、设置选区或触发 IME 操作。每个入口在需要时都会自动聚焦节点,并断言节点已启用且可编辑;如果不满足其中任何一项前置条件,就会给出准确的错误信息。
何时使用此技能
- 测试需要在
TextField、OutlinedTextField、BasicTextField或任何公开InsertTextAtCursor/SetText语义操作的可组合项中输入文本。 - 测试需要触发 IME 操作——使用
ImeAction.Next转移焦点、使用ImeAction.Done收起键盘、使用ImeAction.Search/Send/Go触发表单提交。 - 测试必须清除现有值,或用新文本替换整个值。
- 测试使用
performTextInputSelection(TextRange(...))断言光标或选区状态。
何时不使用此技能
- 测试向已聚焦的字段发送任意按键快捷键(例如
Ctrl+A、Tab、方向键)。请使用../injecting-mouse-and-keyboard/SKILL.md。 - 测试只检查显示的文本,不输入内容——请通过
../../assertions/asserting-node-state-and-text/SKILL.md进行断言。 - 查找器无法定位文本字段。请先通过
../../finders/finding-nodes-by-tag-text-content/SKILL.md验证匹配器。 - 测试断言旋转后输入的文本能够恢复。请参阅
../../setup/configuring-test-dependencies/SKILL.md和StateRestorationTester。
前置条件
- 已根据
../../setup/configuring-test-dependencies/SKILL.md配置androidx.compose.ui:ui-test-junit4(或用于runComposeUiTest的ui-test)。 TextField携带来自生产代码的Modifier.testTag("…"),以便测试能确定性地选中它。- 字段的
enabled = true。performTextInput在执行任何操作前都会调用assert(isEnabled())(TextActions.kt:125)。 - 对于
performImeAction:字段声明了ImeAction != ImeAction.Default(通过KeyboardOptions(imeAction = …))和OnImeAction处理程序(通过KeyboardActions(...)或默认键盘操作)。操作断言会检查这两项(TextActions.kt:99-100)。
工作流
- 在光标处输入文本 —
performTextInput(text)(TextActions.kt:37-41)。内部会通过AnnotatedString(text)调用SemanticsActions.InsertTextAtCursor。如果节点尚未获得焦点,会先自动聚焦该节点(getNodeAndFocus— TextActions.kt:119-138)。如果节点未启用、无法聚焦,或未声明InsertTextAtCursor,则抛出AssertionError("Failed to perform text input.")。
rule.onNodeWithTag(Tag).performTextInput("Hello ")
- 替换整个文本 —
performTextReplacement(text)(TextActions.kt:81-84)。调用SemanticsActions.SetText,清除现有文本并插入新文本。如果字段已有值,而测试要覆盖该值,请使用此方法。不得用它来“输入更多字符”——请参阅下方的错误/正确示例。
rule.onNodeWithTag(Tag).performTextReplacement("New value")
- 清除文本 —
performTextClearance()(TextActions.kt:28-30)。这是performTextReplacement("")的简写。字段必须能够聚焦,并且具有SetText。
rule.onNodeWithTag(Tag).performTextClearance()
- 设置选区/光标 —
performTextInputSelection(selection: TextRange, relativeToOriginalText: Boolean = true)(TextActions.kt:61-72)。映射到SemanticsActions.SetSelection。当relativeToOriginalText = true时,索引指的是原始(未经转换的)文本;为false时,索引指的是经过VisualTransformation处理后的文本。
rule.onNodeWithTag(Tag).performTextInput("Hello World")
rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(0, 5)) // selects "Hello"
- 触发 IME 操作 —
performImeAction()(TextActions.kt:97-117)。前置条件如下,顺序不可更改:assert(hasPerformImeAction())— 字段声明了OnImeAction。assert(!hasImeAction(ImeAction.Default))— 操作不是默认操作。tryPerformAccessibilityChecks()。- 通过
getNodeAndFocus(requireEditable = false)自动聚焦。 - 调用
OnImeAction,并要求其返回true;否则抛出AssertionError("Failed to perform IME action, handler returned false.")。
rule.onNodeWithTag(InitialFieldTag).performImeAction()
- 搭配
assertTextEquals,确认预期的后置状态。合并树默认行为对文本断言和查找器完全相同——请参阅../../assertions/asserting-node-state-and-text/SKILL.md。
模式
模式:追加(performTextInput)与替换(performTextReplacement)
这是最常见的易错点——这两个 API 看起来可以互换,但组合行为不同。
// 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 调用 SemanticsActions.SetText (TextActions.kt:83),会先清除现有字符,再插入新文本——这相当于 IME 的“将组合区域设为整个文本并替换”路径。只要第二次调用应该覆盖而非追加,就应使用它。
模式:标准插入 + 断言
摘自 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!")
}
构造 TextFieldState 时,光标位于 TextRange(Int.MAX_VALUE)(文本末尾)。performTextInput("World!") 会在该光标处插入内容——最终值为 "Hello World!"。状态读取应放在 runOnIdle 中进行(skydoves 的热门观点 #5)。
模式:替换后断言 — 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 会覆盖字段当前的任何值——因此,当底层状态发生轮换时,这是正确的调用方式。
模式:清除后重新填入
rule.onNodeWithTag(Tag).performTextClearance()
rule.onNodeWithTag(Tag).performTextInput("Different")
rule.onNodeWithTag(Tag).assertTextEquals("Different")
performTextClearance 是 performTextReplacement("") 的简写 (TextActions.kt:28-30)。运行后,光标位于位置 0,因此后续的 performTextInput 会直接从开头插入。
模式:IME 操作焦点链(Next 移至下一个焦点)
摘自 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()
}
}
生产代码必须声明非默认的 ImeAction,performImeAction 才有效:
BasicTextField(
state = state,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.testTag(InitialFieldTag),
)
performImeAction 会先断言 !hasImeAction(ImeAction.Default) (TextActions.kt:100)。忘记传入 keyboardOptions 参数会导致 AssertionError("Failed to perform IME action.")。
模式:替换子字符串前先设置选区
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 遵循与 IME 相同的路径,因此后续调用 InsertTextAtCursor 会用新文本替换当前选区。如果字段安装了 VisualTransformation(例如密码掩码、货币格式化器),且索引是根据显示字符串计算的,请传入 relativeToOriginalText = false。
模式:字段已禁用或不可编辑——明确报错
如果字段为 enabled = false,或者匹配器解析到的是 Text 而不是 TextField,performTextInput 会抛出 AssertionError("Failed to perform text input.")。调用链如下:
tryPerformAccessibilityChecks()。assert(isEnabled())。assert(hasRequestFocusAction())。assert(hasSetTextAction())和assert(hasInsertTextAtCursorAction())(TextActions.kt:127-129)。
触发断言时,通过 ../../debug/printing-the-semantics-tree/SKILL.md 转储语义树,确认匹配器没有选中静态的 Text。
强制规则
- 必须使用
performTextReplacement覆盖现有值。不得连续调用两次performTextInput并期望第二次调用替换第一次的内容——它们会在光标处拼接。 - 必须在调用
performImeAction前声明非默认的ImeAction(例如KeyboardOptions(imeAction = ImeAction.Next))。该 API 会断言!hasImeAction(ImeAction.Default)(TextActions.kt:100),否则会抛出异常。 - 必须通过
Modifier.testTag(...)选择字段,且其值必须是生产代码中的常量——skydoves 的 hot take #1。 - 必须通过
rule.runOnIdle { … }执行输入后的状态断言(skydoves 的 hot take #5)。读取TextFieldState.text前,需要等待重组器稳定下来。 - 不得调用
Thread.sleep等待 IME 事件。Compose 的空闲状态检测涵盖文本输入流水线;runOnIdle返回时即可进行断言。 - 除非测试专门覆盖硬件键盘路径,不得通过
performKeyInput { pressKey(Key.A) }注入单个Key事件来模拟文本输入。语义操作才是标准的 IME 路径。 - **建议:**调用
performTextInputSelection时明确指定relativeToOriginalText。默认值(true)适用于大多数情况;仅当字段安装了VisualTransformation时才切换为false。
验证
- 每个会覆盖字段内容的测试,都使用
performTextReplacement(...),或先调用performTextClearance(),再调用performTextInput(...)。不得连续调用performTextInput并期望其具有替换语义。 - 每个
performImeAction调用的目标字段,都在生产代码中声明了KeyboardOptions(imeAction = ImeAction.Next | Done | Search | …)。 - 输入操作后的状态或文本断言,都在
rule.runOnIdle { … }内执行。 - 测试方法中没有
Thread.sleep。 - 针对此次修改的测试,
./gradlew :app:connectedDebugAndroidTest(如果使用 Robolectric,则为:app:testDebugUnitTest)通过。
参考资料
- Compose 测试概览:https://developer.android.com/develop/ui/compose/testing
- Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
- BasicTextField 文档: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)、getNodeAndFocus前置条件链(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— 规范的performTextInput+assertTextEquals(BasicTextFieldTest.kt:171-194)、performTextReplacement(BasicTextFieldTest.kt:442-466)。compose/foundation/foundation/src/androidDeviceTest/.../DefaultKeyboardActionsTest.kt—performImeAction+ IME 操作的焦点链(DefaultKeyboardActionsTest.kt:130-149)。- skydoves — compose-performance-skills:https://github.com/skydoves/compose-performance-skills