mcpField
I
injecting-mouse-and-keyboard

injecting-mouse-and-keyboard

github.com/skydovesテスト・品質33961 回インストール22 週間前に更新
リポジトリ

injecting-mouse-and-keyboard について

Compose UI テストでマウス、キーボード、複数モダリティの入力を行うためのスキルガイドです。デスクトップや Compose Multiplatform のテストを対象に、入力スコープ、ノード内座標、ショートカット、入力間で共有される状態を説明します。

主な機能

  1. 01マウスクリック、ホバー、スクロールの注入
  2. 02キー入力とショートカットの注入
  3. 03タッチ、マウス、キーボードの組み合わせ
  4. 04マウス移動とドラッグ&ドロップ
  5. 05キーとポインター状態の管理

活用シーン

  1. 01デスクトップ画面の右クリックメニューのテスト
  2. 02ホバーツールチップや状態の検証
  3. 03Ctrl+S などのショートカットのテスト
  4. 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
  1. マウススコープを開く — performMouseInput(block: MouseInjectionScope.() -> Unit) (Actions.kt:448-461)。タッチと同様に、イベントはバッチ処理され、ブロックの終了時にフラッシュされます。ブロック内で再コンポジションが割り込むことはありません。

  2. ノードローカル座標を使用します。 (0, 0) はノードの左上で、TouchInjectionScope と同じです。MouseInjectionScope は InjectionScope を拡張しているため、center、topLeft、bottomRight、percentOffset(...) をすべて使用できます (InjectionScope.kt:34-206)。

  3. 適切なヘルパーを選択します。 すべて 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)垂直スクロールイベントを送信します
  1. 必要に応じて低レベルイベントを使用します:

    • 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 — 最後に確認されたマウス位置です。
  2. Mouse.kt の MouseButton と ScrollWheel を参照します:

    • MouseButton.Primary、MouseButton.Secondary、MouseButton.Tertiary — expect value class MouseButton。
    • ScrollWheel.Vertical、ScrollWheel.Horizontal — value class ScrollWheel。
キーボード:performKeyInput
  1. キーのスコープを開きます — performKeyInput(block: KeyInjectionScope.() -> Unit)(Actions.kt:532-545)。

  2. キーイベントを送信します。 コアとなる 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)。これらはホストマシンではなく、入力された状態を反映します。
  3. ヘルパーを使います(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 に便利です。
  4. キーリピートの動作。 キーを押したままにし、InjectionScope の advanceEventTime でイベント時刻を進めると、リピートイベントが発生します。最初のリピートは 500 ms 後、その後は 50 ms ごとに発生します(KeyInjectionScope.kt:43-50)。これは MainTestClock.advanceTimeBy では発生しません。こちらは入力イベント時刻ではなく、テストクロックを進めます。

  5. KeyEvent を直接入力する — KeyInputHelpers.kt:27 の SemanticsNodeInteraction.performKeyPress(KeyEvent): Boolean を使います。イベントが処理された場合は true を返します。すでに KeyEvent がある場合(細かな入力元やスキャンコードの制御のために KeyEvent(NativeKeyEvent(...)) で構築した場合など)に使用します。それ以外の場合は DSL を推奨します。

マルチモーダル:performMultiModalInput
  1. マルチモーダルスコープを開きます — performMultiModalInput(block: MultiModalInjectionScope.() -> Unit)(Actions.kt:582-594)。

  2. サブスコープに処理を振り分けます — touch { … }、mouse { … }、key { … }、rotary { … }、trackpad { … }、indirectPointer(...)(MultiModalInjectionScope.kt:51-90)。すべてのサブスコープは同じ入力状態を共有するため、touch で押したままにした指は、次に mouse を実行するときも押されたままです。

  3. 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

コメント