mcpField
E
entering-text

entering-text

github.com/skydoves测试与质量33961 次安装22 周前更新
代码仓库

关于 entering-text

此技能介绍如何在 Compose UI 测试中通过语义操作输入、替换和清除文本、设置选区及触发 IME 操作。它面向测试 TextField 等可编辑组件的开发者,并说明各操作的前置条件与差异。

主要功能

  1. 01在光标处输入文本
  2. 02替换或清除字段内容
  3. 03设置文本选区
  4. 04触发 IME 操作
  5. 05通过测试标签定位字段

适用场景

  1. 01验证文本字段的输入与替换行为
  2. 02测试 Next 或 Done 等 IME 操作
  3. 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)。

工作流

  1. 在光标处输入文本 — 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 ")
  1. 替换整个文本 — performTextReplacement(text) (TextActions.kt:81-84)。调用 SemanticsActions.SetText,清除现有文本并插入新文本。如果字段已有值,而测试要覆盖该值,请使用此方法。不得用它来“输入更多字符”——请参阅下方的错误/正确示例。
rule.onNodeWithTag(Tag).performTextReplacement("New value")
  1. 清除文本 — performTextClearance() (TextActions.kt:28-30)。这是 performTextReplacement("") 的简写。字段必须能够聚焦,并且具有 SetText。
rule.onNodeWithTag(Tag).performTextClearance()
  1. 设置选区/光标 — 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"
  1. 触发 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()
  1. 搭配 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.")。调用链如下:

  1. tryPerformAccessibilityChecks()。
  2. assert(isEnabled())。
  3. assert(hasRequestFocusAction())。
  4. 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)通过。

参考资料

评论

支持 Markdown