关于 injecting-mouse-and-keyboard
面向 Compose UI 测试的鼠标、键盘和多模态输入技能指南,适用于桌面或 Compose Multiplatform 界面测试。它说明输入范围、节点局部坐标、快捷键及跨模态共享状态的用法。
主要功能
- 01注入鼠标点击、悬停与滚动
- 02注入键盘按键和快捷键
- 03组合触摸、鼠标与键盘输入
- 04执行鼠标拖放和移动
- 05控制按键及指针状态
适用场景
- 01测试桌面界面的右键菜单
- 02验证悬停提示和悬停状态
- 03测试 Ctrl+S 等键盘快捷键
- 04模拟拖放及组合输入操作
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill injecting-mouse-and-keyboard安装后在 Claude Code 中输入/injecting-mouse-and-keyboard确认已加载。
详细介绍
注入鼠标和键盘输入 — performMouseInput、performKeyInput、performMultiModalInput
在 Compose 测试中,鼠标和键盘输入是一级输入方式。两者各自都有注入作用域和入口,此外还有一个共享的 performMultiModalInput,可在一次批处理手势中组合所有输入方式(触摸、鼠标、按键、旋转、触控板、间接指针)。坐标始终相对于节点本地,与触摸采用相同规则;在同一节点上,完整的注入状态会跨 perform.*Input 代码块保留。
何时使用此技能
- 测试针对桌面或 Compose Multiplatform UI,该 UI 会响应点击、悬停、右键单击、滚轮或修饰键。
- 测试必须触发键盘快捷键(例如
Ctrl+S、Shift+Tab、方向键导航)。 - 测试必须验证仅悬停时的行为:工具提示弹出、
Modifier.hoverable的颜色变化、仅鼠标触发的涟漪状态。 - 测试通过鼠标指针模拟拖放操作。
- 测试必须组合多种输入方式,例如“悬停在 (x, y) 处,然后在按住
Shift的同时点击并按住”。
何时不使用此技能
- 交互是手指点击或拖动 — 使用
../injecting-touch-gestures/SKILL.md。在 Android 设备测试中,鼠标不能替代触摸。 - 简单的
performClick()已会根据平台使用正确的输入方式 — 使用../clicking-and-scrolling/SKILL.md。 - 测试在
TextField中输入内容 — 使用../entering-text/SKILL.md。此处的按键输入用于快捷键,而非文本输入。 - 测试等待由悬停触发的动画结束 — 先暂停时钟,参阅
../../synchronization/testing-animations-deterministically/SKILL.md。
前提条件
- 按照
../../setup/configuring-test-dependencies/SKILL.md配置androidx.compose.ui:ui-test-junit4(或用于runComposeUiTest的ui-test)。 - 在 Android 上接收鼠标悬停事件:需要使用支持悬停的平台级别(通常为 API 23+)——Robolectric 主机测试也可运行,但请检查
@Config(minSdk = …)。 - 若按键输入要作用于可聚焦节点,该节点必须可聚焦(例如使用
Modifier.focusable()或Modifier.focusRequester(...)包装)。否则,按键事件会路由到当前获得焦点的根节点。
工作流
Mouse:performMouseInput
-
打开鼠标作用域 —
performMouseInput(block: MouseInjectionScope.() -> Unit)(Actions.kt:448-461)。与触摸操作类似,事件会批量处理,并在代码块返回时发送;重组不会在代码块内穿插执行。 -
使用节点局部坐标。
(0, 0)是节点的左上角,与TouchInjectionScope相同。MouseInjectionScope扩展了InjectionScope,因此可以使用center、topLeft、bottomRight、percentOffset(...)(InjectionScope.kt:34-206)。 -
选择合适的辅助方法。 均位于
MouseInjectionScope.kt中:
| 辅助方法 | 默认值 | 功能 |
|---|---|---|
click(position = center, button = MouseButton.Primary) | 在中心位置单击主键 | 按下 → 等待 60 ms → 释放 |
rightClick(position = center) | 在中心位置单击副键 | click(position, MouseButton.Secondary) 的简写 |
doubleClick(position = center, button = Primary) | 位于双击间隔最小值与最大值的中间 | click(); advanceEventTime(delay); click() |
tripleClick(position = center, button = Primary) | 与双击相同的间隔 | 连续单击三次 |
longClick(position = center, button = Primary) | 按住 longPressTimeoutMillis + 100 ms | 按下、按住、释放 |
animateMoveTo(position, durationMillis = 300) | 300 ms | 沿线性路径持续发送移动事件 |
animateMoveBy(delta, durationMillis = 300) | 相对形式 | — |
animateMoveAlong(curve, durationMillis = 300) | 任意曲线 | — |
dragAndDrop(start, end, button = Primary, durationMillis = 300) | 主键拖动 | updatePointerTo(start); press; animateMoveTo(end); release |
smoothScroll(scrollAmount, durationMillis = 300, scrollWheel = Vertical) | 垂直 | 持续发送滚动事件 |
-
需要时使用底层事件:
press(button: MouseButton = Primary)/release(button: MouseButton = Primary)。moveTo(position, delayMillis = eventPeriodMillis)/moveBy(delta, delayMillis = eventPeriodMillis)。updatePointerTo(position)/updatePointerBy(delta)— 调整位置但不发送事件。enter(position)/exit(position)— 显式触发悬停进入 / 悬停退出。scroll(delta, scrollWheel = Vertical)— 单次滚轮事件。cancel(delayMillis)— 发送ACTION_CANCEL。currentPosition: Offset— 鼠标最后已知的位置。
-
从
Mouse.kt引用MouseButton和ScrollWheel:MouseButton.Primary、MouseButton.Secondary、MouseButton.Tertiary—expect value class MouseButton。ScrollWheel.Vertical、ScrollWheel.Horizontal—value class ScrollWheel。
键盘:performKeyInput
-
打开按键作用域 —
performKeyInput(block: KeyInjectionScope.() -> Unit)(Actions.kt:532-545)。 -
发送按键事件。 核心接口(KeyInjectionScope.kt:55-107):
keyDown(key: Key)/keyUp(key: Key)— 基本操作。若状态无效(已按下、已抬起),则抛出IllegalStateException。isKeyDown(key: Key): Boolean— 查询注入状态。- 修饰键状态属性:
isCtrlDown、isAltDown、isShiftDown、isMetaDown、isFnDown(KeyInjectionScope.kt:236-269)、isCapsLockOn、isNumLockOn、isScrollLockOn(KeyInjectionScope.kt:64-81)。这些反映的是注入的状态,而非主机上的状态。
-
使用辅助方法(KeyInjectionScope.kt:146-229):
pressKey(key, pressDurationMillis = 50L)—keyDown(key); advanceEventTime(50); keyUp(key)。withKeyDown(key) { … }— 在按住key的同时运行block;并在finally中自动释放。不得在block内使用已按住的键。withKeysDown(listOf(key1, key2)) { … }— 同上,但同时按住多个键。withKeyToggled(key) { … }/withKeysToggled(keys) { … }— 在block执行前后调用pressKey。适用于CapsLock、NumLock、ScrollLock。
-
按键重复行为。 按住某个键并推进事件时间(通过
InjectionScope中的advanceEventTime)会产生重复事件:首次重复在 500 ms 后触发,之后每隔 50 ms 触发一次(KeyInjectionScope.kt:43-50)。这不会由MainTestClock.advanceTimeBy触发——它推进的是测试时钟,而不是注入事件时间。 -
直接注入 KeyEvent —
KeyInputHelpers.kt:27中的SemanticsNodeInteraction.performKeyPress(KeyEvent): Boolean。如果事件已被消费,则返回true。当你已有KeyEvent时使用(例如通过KeyEvent(NativeKeyEvent(...))构造,以便精细控制来源/扫描码);否则优先使用 DSL。
多模态:performMultiModalInput
-
打开多模态作用域 —
performMultiModalInput(block: MultiModalInjectionScope.() -> Unit)(Actions.kt:582-594)。 -
分派到子作用域 —
touch { … }、mouse { … }、key { … }、rotary { … }、trackpad { … }、indirectPointer(...)(MultiModalInjectionScope.kt:51-90)。所有子作用域共享相同的注入状态,因此在touch中保持“按下”的手指,在接下来运行mouse时仍处于按下状态。 -
在单个测试步骤组合多种输入方式时选择此入口点——例如,按住 Shift 键的同时悬停,而不是拼接两个独立的
perform.*Input调用。
模式
模式:右键菜单
// RIGHT
rule.onNodeWithTag(RowTag).performMouseInput {
rightClick(center)
}
rule.onNodeWithText("Delete").assertIsDisplayed()
rightClick 是 click(position, MouseButton.Secondary) 的简写(MouseInjectionScope.kt:356-357)。标签中使用“右”是为了便于理解,但它实际触发的是次要按键——对于左手鼠标同样正确。
模式:悬停触发的工具提示
摘自 CombinedClickableTest.kt:3415-3422:
rule.onNodeWithTag("myClickable").performMouseInput { enter(center) }
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
}
rule.onNodeWithTag("myClickable").performMouseInput { exit(Offset(-1f, -1f)) }
enter(position) 会发出悬停进入事件;exit(position) 会发出悬停退出事件。将节点外的 Offset(-1f, -1f) 传给 exit,即可模拟光标离开表面。
模式:Ctrl+S 快捷键——冗长写法与惯用写法
// WRONG
rule.onNodeWithTag(EditorTag).performKeyInput {
keyDown(Key.CtrlLeft)
pressKey(Key.S)
keyUp(Key.CtrlLeft)
}
// WRONG because: a thrown assertion or early-return inside the block leaks Key.CtrlLeft
// in the "down" state, polluting subsequent tests. The release is not in a finally.
// RIGHT
rule.onNodeWithTag(EditorTag).performKeyInput {
withKeyDown(Key.CtrlLeft) {
pressKey(Key.S)
}
}
withKeyDown 会在 finally 块中释放按键(KeyInjectionScope.kt:164-171)。对于包含多个修饰键的快捷键,请使用 withKeysDown(listOf(Key.CtrlLeft, Key.ShiftLeft)) { pressKey(Key.S) }。
模式:按键自动重复
rule.onNodeWithTag(InputTag).performKeyInput {
keyDown(Key.DirectionDown)
advanceEventTime(800) // 500 ms initial delay + 6 repeats at 50 ms
keyUp(Key.DirectionDown)
}
advanceEventTime 是输入分发器时钟——它会按文档所述的频率触发重复事件(首次重复在 500 ms 后,之后每隔 50 ms 触发一次——KeyInjectionScope.kt:43-50)。不得替换为 rule.mainClock.advanceTimeBy(…):主测试时钟推进的是 Compose 的帧时钟,而非输入分发器的事件时间,因此不会将重复事件加入队列。
模式:使用鼠标拖放
rule.onNodeWithTag(SourceTag).performMouseInput {
dragAndDrop(
start = center,
end = Offset(center.x + 200f, center.y),
durationMillis = 250,
)
}
dragAndDrop 会将指针移到 start,按下鼠标键,将指针移动到 end,然后松开鼠标键(MouseInjectionScope.kt:523-533)。默认按钮为 MouseButton.Primary;中键拖动时,传入 button = MouseButton.Tertiary。
模式:平滑滚动
rule.onNodeWithTag(ScrollAreaTag).performMouseInput {
smoothScroll(
scrollAmount = -10f, // negative scrollAmount = scroll back; new content appears at the top (MouseInjectionScope.kt:537-542)
durationMillis = 200,
scrollWheel = ScrollWheel.Vertical,
)
}
正值 scrollAmount 会显示竖列底部或横行末尾的内容(MouseInjectionScope.kt:537-542)。如需单次滚轮事件,可改用 scroll(delta, scrollWheel)。
模式:多模态——悬停后在按住 Shift 时单击
MultiModalInjectionScope 提供 mouse { }、key { }、touch { } 等子作用域;一次 key { withKeyDown(...) { } } 调用设置的修饰键状态,会在外围 performMultiModalInput 的剩余部分持续生效,因此后续的 mouse { } 块会在 Shift 仍处于按下状态时派发。withKeyDown 块本身以 KeyInjectionScope 为接收者——在其中调用 mouse { } 无法编译。
rule.onNodeWithTag(NodeTag).performMultiModalInput {
mouse { enter(center) }
key { keyDown(Key.ShiftLeft) }
mouse { click() } // dispatched with Shift still held
key { keyUp(Key.ShiftLeft) }
}
一次 performMultiModalInput 会将所有输入模态保留在同一批次中统一刷新;各子作用域共享修饰键状态、指针位置和按下的鼠标键(MultiModalInjectionScope.kt)。需要交错使用多种输入模态时使用它;否则,分别调用 performMouseInput / performKeyInput 会更清晰。
模式:直接注入 KeyEvent,以实现精确控制
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.performKeyPress
val event = KeyEvent(NativeKeyEvent(NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_TAB))
rule.onRoot().performKeyPress(event)
performKeyPress 会返回事件是否被消费(KeyInputHelpers.kt:27)。它绕过 DSL,适合重放捕获的 KeyEvent 或测试由 IME 生成的按键输入。
强制规则
- 必须使用
withKeyDown/withKeysDown包裹修饰键快捷键,以便在断言抛出异常后,由自动释放的finally块完成清理。手动配对调用keyDown/keyUp会导致修饰键状态泄漏到其他测试中。 - 必须在需要触发按键自动重复事件时使用
advanceEventTime(即InjectionScope时钟),不得使用MainTestClock.advanceTimeBy。重复事件由输入派发器的事件时间流生成(KeyInjectionScope.kt:43-50)。 - 必须使用
center、topLeft、bottomRight、percentOffset(...)等保持坐标相对于节点。硬编码屏幕坐标会让测试依赖设备形状——参见../injecting-touch-gestures/SKILL.md。 - 必须通过
rule.runOnIdle { … }执行操作后的断言(skydoves 的观点 #5)。 - 不得在 Android 设备测试中使用
performMouseInput测试手指点击——performClick()会自动在 Android 上解析为触摸点击,在桌面端解析为鼠标单击。 - 不得使用
Thread.sleep等待按键或鼠标事件之间的间隔。在代码块内使用advanceEventTime(事件时间延迟),或在代码块外使用mainClock.advanceTimeBy(帧时钟延迟)。 - **建议:**仅在确实需要交错使用多种输入模态时选择
performMultiModalInput;否则,分别调用performMouseInput和performKeyInput可读性更好。
验证
- 所有修饰键组合都使用
withKeyDown/withKeysDown,而非直接配对调用keyDown/keyUp。 -
performMouseInput/performKeyInput块之间或块内没有使用Thread.sleep。 - 没有硬编码屏幕坐标——每个
Offset(...)都由center、top*、bottom*、percentOffset(...)或相对位移计算得出。 - 测试按键自动重复行为的断言使用
advanceEventTime,而非mainClock.advanceTimeBy。 - 操作后的断言在
rule.runOnIdle { … }内执行。 - 对于所修改的测试,
./gradlew :app:connectedDebugAndroidTest(或对应的宿主端 / 桌面任务)运行通过。
参考资料
- Compose 测试概述:https://developer.android.com/develop/ui/compose/testing
- Compose Multiplatform 测试:https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
- Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt—performMouseInput(Actions.kt:448)、performKeyInput(Actions.kt:532)、performMultiModalInput(Actions.kt:582)、performTrackpadInput、performRotaryScrollInput。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MouseInjectionScope.kt—press、release、moveTo、moveBy、enter、exit、scroll、cancel,以及扩展函数click、rightClick、doubleClick、tripleClick、longClick、animateMoveTo、animateMoveBy、animateMoveAlong、dragAndDrop、smoothScroll。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInjectionScope.kt—keyDown、keyUp、isKeyDown、修饰键状态 vals、pressKey、withKeyDown、withKeysDown、withKeyToggled、withKeysToggled,以及重复按键约定。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInputHelpers.kt—performKeyPress(KeyEvent): Boolean(KeyInputHelpers.kt:27)。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Mouse.kt—MouseButton.Primary/Secondary/Tertiary、ScrollWheel.Horizontal/Vertical。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MultiModalInjectionScope.kt—touch、mouse、key、rotary、trackpad、indirectPointer。compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt—performMouseInput { enter(center) }、performMouseInput { exit(Offset(-1f, -1f)) }(CombinedClickableTest.kt:3415-3422)。- skydoves — compose-performance-skills:https://github.com/skydoves/compose-performance-skills