mcpField
E
entering-text

entering-text

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

entering-text について

Compose UIテストのセマンティクス操作で、テキスト入力・置換・消去、選択範囲の設定、IME操作の実行を行うスキルです。TextFieldなど編集可能なコンポーザブルのテスト向けに、各操作の前提条件と違いを説明します。

主な機能

  1. 01カーソル位置へのテキスト入力
  2. 02フィールド内容の置換・消去
  3. 03テキスト選択範囲の設定
  4. 04IME操作の実行
  5. 05テストタグによるフィールド検索

活用シーン

  1. 01テキストフィールドの入力・置換動作のテスト
  2. 02Nextや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 はそれらのアクションをラップし、1 回の呼び出しでテキストの入力、置換、クリア、選択範囲の設定、または 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)

最もよくある落とし穴です。2 つの 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 の「テキスト全体を合成領域に設定して置き換える」動作と同等です。2 回目の呼び出しで追記ではなく上書きする場合は、必ずこれを使用してください。

パターン:標準的な挿入+アサーション

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()
    }
}

performImeAction を有効にするには、本番コードでデフォルト以外の ImeAction を宣言する必要があります。

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 が設定されている場合、またはマッチャーが TextField ではなく Text を特定した場合、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 を使ってください。2 回目の performTextInput で 1 回目の入力を置き換えることを期待して、2 回連続で呼び出してはいけません。カーソル位置に連結されます。
  • 必須: 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 を読み取る前に、リコンポーザーが処理を完了する必要があります。
  • 禁止: IME イベントを待つために Thread.sleep を呼び出してはいけません。Compose のアイドル状態の監視にはテキスト入力パイプラインが含まれるため、runOnIdle が戻る時点でアサーションを実行できます。
  • 禁止: テストがハードウェアキーボード経由の処理を特に検証する場合を除き、performKeyInput { pressKey(Key.A) } で個々の Key イベントを注入してテキスト入力を再現してはいけません。セマンティクスアクションが標準的な IME 経路です。
  • 推奨: performTextInputSelection の呼び出しでは、relativeToOriginalText を明示してください。デフォルト(true)はほとんどの場合に適切です。フィールドに VisualTransformation が設定されている場合にのみ切り替えてください。

検証

  • フィールドの内容を上書きするすべてのテストで、performTextReplacement(...)、または performTextClearance() の後に performTextInput(...) を使用していること。置き換えられると期待して performTextInput を連続して呼び出していないこと。
  • すべての performImeAction 呼び出しで、本番ソース内に KeyboardOptions(imeAction = ImeAction.Next | Done | Search | …) を宣言したフィールドを対象としていること。
  • 入力アクション後の状態またはテキストのアサーションが、rule.runOnIdle { … } 内で実行されていること。
  • テストメソッドに Thread.sleep がないこと。
  • ./gradlew :app:connectedDebugAndroidTest(Robolectric の場合は :app:testDebugUnitTest)が、変更対象のテストで成功すること。

参考資料

コメント