injecting-mouse-and-keyboard について
Compose UI テストでマウス、キーボード、複数モダリティの入力を行うためのスキルガイドです。デスクトップや Compose Multiplatform のテストを対象に、入力スコープ、ノード内座標、ショートカット、入力間で共有される状態を説明します。
主な機能
- 01マウスクリック、ホバー、スクロールの注入
- 02キー入力とショートカットの注入
- 03タッチ、マウス、キーボードの組み合わせ
- 04マウス移動とドラッグ&ドロップ
- 05キーとポインター状態の管理
活用シーン
- 01デスクトップ画面の右クリックメニューのテスト
- 02ホバーツールチップや状態の検証
- 03Ctrl+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 のテストで第一級の入力モダリティです。それぞれに独自のインジェクションスコープとエントリーポイントがあり、さらに、タッチ、マウス、キー、ロータリー、トラックパッド、間接ポインターを1つのバッチ化されたジェスチャー内で組み合わせる、共通の performMultiModalInput があります。座標はノードローカルのままです。タッチと同じルールが適用されます。また、インジェクション状態全体が、同じノード上での perform.*Input ブロック間で保持されます。
このスキルを使う場面
- テスト対象が、クリック、ホバー、右クリック、スクロールホイール、修飾キーに反応するデスクトップ UI または Compose Multiplatform 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 レベル(通常は API 23 以降)が必要です。Robolectric のホストテストは動作しますが、
@Config(minSdk = …)を確認してください。 - フォーカス可能なノードを対象とするキー入力の場合、ノードがフォーカス可能である必要があります(例:
Modifier.focusable()またはModifier.focusRequester(...)でラップする)。そうでない場合、キーイベントはフォーカスされたルートに送られます。
ワークフロー
マウス: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) | ダブルクリックと同じ間隔 | 連続して 3 回クリック |
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)— ホイールイベントを 1 回送信します。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)。 -
キーイベントを送信します。 コアとなる API(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を実行するときも押されたままです。 -
1 つのテスト手順で複数のモダリティを組み合わせる場合(たとえば、Shift キーを押しながらホバーする場合)は、別々の
perform.*Input呼び出しをつなぎ合わせる代わりに、このエントリーポイントを選びます。
パターン
パターン:右クリックメニュー
// RIGHT
rule.onNodeWithTag(RowTag).performMouseInput {
rightClick(center)
}
rule.onNodeWithText("Delete").assertIsDisplayed()
rightClick は click(position, MouseButton.Secondary) の省略形です(MouseInjectionScope.kt:356-357)。「right」というラベルは馴染みやすさのためですが、実際にはセカンダリボタンをトリガーするため、左利き用マウスでも正しく動作します。
パターン:ホバーで表示するツールチップ
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)。ホイールイベントを1ティックだけ送るには、scroll(delta, scrollWheel) を使用します。
パターン:マルチモーダル — Shift キーを押したままホバーしてクリック
MultiModalInjectionScope は、サブスコープとして mouse { }、key { }、touch { } などを公開します。1回の 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) }
}
1つの 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の組み合わせでは、テスト間で修飾キーの状態が残ります。 - 必須: キーの自動リピートイベントを期待する場合は、
MainTestClock.advanceTimeByではなく、InjectionScopeのクロックであるadvanceEventTimeを使用します。リピートは入力ディスパッチャーのイベント時刻ストリームによって生成されます(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を別々に呼び出すほうが読みやすくなります。
検証
- 修飾キーの組み合わせではすべて、
keyDown/keyUpの直接的な組み合わせではなく、withKeyDown/withKeysDownを使用している。 -
performMouseInput/performKeyInputブロックの前後または内部にThread.sleepがない。 - 画面座標をハードコードしていない。すべての
Offset(...)がcenter、top*、bottom*、percentOffset(...)、または相対的な差分から導出されている。 - キーの自動リピート動作を検証するテストでは、
mainClock.advanceTimeByではなくadvanceEventTimeを使用している。 - 操作後のアサーションを
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、修飾キーの状態を示す val、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