iOS の IME 変換中にフォーム検証を走らせないためのテスト

iOS の IME 変換中にフォーム検証を走らせないためのテスト

クラウド Mac 上の iOS シミュレータで、中国語の氏名を入力する。ピンインから漢字を選ぶ前にフォームには「中国語で入力してください」と表示され、候補を確定した直後には送信ボタンが一瞬無効になる。原因は正規表現の間違いではなく、IME で変換中のテキストを確定済みの値として扱っていることが多い。再現して修正するには、text プロパティだけを見るのではなく、フィールドの状態を「変換中」と「確定後」に分けて考える必要がある。

検証対象の状態を先に決める

中国語のピンイン入力、日本語のかな漢字変換、韓国語のハングル入力には、いずれも候補の選択や文字の組み立てを行う段階がある。UITextInput に準拠する UITextField では、markedTextRange が nil でなければ、現在マーク付きテキストが存在する。この間も入力中の文字は画面に表示してよいが、それを根拠に形式エラーを出したり、送信を許可したり、確定値として保存したりしてはいけない。

入力イベントの発生は、入力の確定を意味しない。変換中のテキストは編集過程の一部であり、業務上の検証は確定後の値に対して行う。

まずフィールドの扱いを決める。変換中は以前のエラーを消して形式チェックを一時停止し、変換が終わったら現在の text で再検証する。送信時には変換が終わっているかを確認し、もう一度検証する。最後の確認を省くと、候補を確定していないユーザーが別の操作から送信できてしまう。空欄を許すか、氏名を漢字に限定するかは業務ルールであり、変換中かどうかの判定とは分けて扱う。

フィールドのイベントでは判定を保留する

次のオブジェクトは、検証関数を呼ぶタイミングだけを制御する。例では空欄を許さず、漢字のみを有効とする。実際のアプリでは isValid に相当するルールを要件に合わせて差し替え、氏名のルールをメールアドレスや住所に流用しないこと。

import UIKit

@MainActor
final class NameFieldValidator {
    private(set) var message: String?
    private(set) var canSubmit = false

    func editingDidChange(_ field: UITextField) {
        guard field.markedTextRange == nil else {
            message = nil
            canSubmit = false
            return
        }
        evaluate(field.text ?? "")
    }

    func submit(_ field: UITextField) -> Bool {
        guard field.markedTextRange == nil else {
            canSubmit = false
            return false
        }
        evaluate(field.text ?? "")
        return canSubmit
    }

    private func evaluate(_ value: String) {
        canSubmit = !value.isEmpty &&
            value.unicodeScalars.allSatisfy {
                (0x3400...0x9FFF).contains(Int($0.value))
            }
        message = canSubmit ? nil : "请填写中文姓名"
    }
}

ビューコントローラでは .editingChanged を editingDidChange(_:) に接続し、message と canSubmit に応じてメッセージとボタンを更新する。マーク付きテキストの変更でイベントが発火するかどうかは、入力経路によって異なる。「候補の確定」が必ず一度の .editingChanged に対応するとは考えず、編集終了時と送信処理の入口でもフィールドの状態を読み直す。プログラムから text を変更した場合も、直接代入はユーザーのキー入力と同じではないため、画面を明示的に更新する。例のコードポイント範囲は処理の説明用であり、氏名に使われるすべての文字、拡張漢字、区切り記号を網羅するものではない。

単体テストで状態遷移を固定する

アプリの単体テストターゲットで実際の UITextField を作り、変換中は送信できず、確定後に初めて最終的なテキストで判定されることを確認する。UIKit のオブジェクトはメインスレッドで扱う。このテストは画面上のキーボードには依存しない。

import XCTest
import UIKit

@MainActor
final class NameFieldValidatorTests: XCTestCase {
    func testMarkedTextIsNotSubmitted() {
        let field = UITextField()
        let validator = NameFieldValidator()

        field.setMarkedText("zhong", selectedRange: NSRange(location: 5, length: 0))
        validator.editingDidChange(field)

        XCTAssertNotNil(field.markedTextRange)
        XCTAssertFalse(validator.canSubmit)
        XCTAssertFalse(validator.submit(field))

        field.setMarkedText("中文", selectedRange: NSRange(location: 2, length: 0))
        field.unmarkText()
        validator.editingDidChange(field)

        XCTAssertNil(field.markedTextRange)
        XCTAssertEqual(field.text, "中文")
        XCTAssertTrue(validator.submit(field))
    }
}

テスト対象の型をテストターゲットから参照できるようにしてから、クラウド Mac の Xcode で Test アクションを実行する。CI に組み込む際は、まず Xcode で scheme を共有し、xcodebuild -showdestinations -scheme <方案名> -project <工程名>.xcodeproj で利用可能なシミュレータを確認する。選んだ platform=iOS Simulator,id=<设备ID> を xcodebuild test に渡す。デバイス ID は実行するマシンで取得し、別のマシンの ID をスクリプトに書き込まない。

このテストが確認するのは、フィールドの状態に対するバリデータの反応であり、システムキーボードがイベントを送る順序ではない。markedTextRange のアサーションが失敗したら、まずテスト用フィールドがマーク付きテキストに対応しているか、次に呼び出しがメインスレッドで実行されているかを確認する。テストを通すために変換中の状態を確認するアサーションを削除してはいけない。

シミュレータで実際の入力経路を確かめる

単体テストが通ったら、クラウド Mac の GUI から対象の iOS シミュレータを開く。システム設定で対象の入力方法を有効にし、現在のキーボードが実際にその入力方法へ切り替わっていることを確認する。入力をそのまま物理キーボードのテキストとして送る設定をオフにしてから、氏名欄を操作する。

  1. ピンインを入力し、候補を選ばずにエラー表示と送信ボタンを確認する。どちらも未確定のテキストを有効な氏名として扱ってはならない。
  2. 漢字の候補を選び、メッセージ、ボタン、フィールドの値が一致するか確認する。続けて一文字削除し、確定後の値に合わせて検証結果が更新されるか確かめる。
  3. 変換中にキーボードの Return キーや別の送信経路から送信を試みる。ボタンが押せないように見えるだけでなく、送信処理自体が拒否することを確認する。
  4. 対象のシステムバージョンと入力方法を切り替えて繰り返す。完成した漢字を貼り付けるだけの自動化スクリプトでは、最も重要な変換過程を検証できない。

「候補を確定したのに再検証されない」場合は、.editingChanged、編集終了時、送信処理の入口での状態を一時的に記録すると、取りこぼしたイベントを調べられる。デバッグ出力はローカルでの原因調査に限り、ユーザーの入力内容を永続ログに残さない。

境界を適切な層に置く

実際のフォームでは、自動入力、貼り付け、音声入力、プログラムによる値の設定も起こり得る。これらの経路ではマーク付きテキストが発生するとは限らないが、フィールドの値は変わり得る。そのため、送信時には独立して最終確認を行う。一方、markedTextRange が nil でないからといって、編集中の文字を捨ててはいけない。誤判定につながる業務上の検証だけを一時停止し、入力中の表示は維持する。

受け入れ確認では、候補が出る前に誤ったエラーが表示されないか、確定後に速やかに更新されるか、ボタンを介さない送信もチェックを通るか、の三点を見る。それぞれを分けてテストしてこそ、クラウド Mac で再現した入力の問題を「漢字が入力できた」という一枚のスクリーンショットで見落とさずに済む。

よくある質問

IME の変換中に検証を待つ理由は何ですか?

markedTextRange がある間は候補の選択で文字列が変わります。変換が終わってから確定した値を検証します。

XCTest だけでキーボード操作を確認できますか?

できません。XCTest は検証ロジックを確認できますが、候補の選択やイベント順序は対象バージョンのシミュレータでも確認します。

専用物理ノードで試す

次のタスクに合わせてクラウドMacを用意

モデルのメモリ要件やビルド負荷に合わせてMac miniをお選びください。3つの構成を、日・週・月・四半期単位でご利用いただけます。ノードの空き状況は管理画面にリアルタイムで表示されます。

クラウドMacを借りる