iOS の取り消しとやり直しを操作単位でテストする

iOS の取り消しとやり直しを操作単位でテストする

ローカルでは問題なく取り消せるエディタでも、クラウド Mac でテストすると「1 回のドラッグを戻すのに 10 回取り消す必要がある」「取り消しはできるのに、やり直しができない」といった問題が起きることがあります。まずボタンの処理を追加するのではなく、編集操作、履歴管理、画面の更新を切り分け、1 回のユーザー操作がいくつの履歴グループになるかを確認しましょう。ここではキャンバスの座標を整数の位置に置き換え、iOS のユニットテストターゲットに組み込める最小限の契約を定義します。

まず取り消す操作の単位を決める

入力を 3 種類に分けます。ボタンによる単発の調整、連続したドラッグ、プログラムによる文書の読み込みです。ボタンによる調整は 1 グループ、ドラッグは開始から終了までを 1 グループとし、文書の読み込みは編集履歴に含めません。位置が通知されるたびに、それを 1 回のユーザー操作として扱わないことが重要です。

UndoManager は、デフォルトではイベントループに合わせて自動的にグループ化できます。テストがこの挙動に依存していると、実行タイミングの変化によってグループの境界が変わる可能性があります。ここでは groupsByEvent を無効にし、アプリケーション側の操作の入口で開始と終了を明示的に対応させます。実際の画面でも同じ方針を使い、テストだけで無効にすることは避けてください。

モデルの変更と履歴操作は、すべてメインアクター上で行います。テストを始める前に groupingLevel がゼロであることを確認し、閉じていないグループがある状態で取り消しを実行しないでください。ドラッグをキャンセルしたときに開始位置へ戻すのか、現在の位置を維持するのかは、先に製品の仕様として決めてから、それぞれに対応する処理を実装します。

合格条件は「ボタンを押せること」ではなく、「1 回の取り消しで操作前に戻り、1 回のやり直しで操作後に戻ること」です。画面表示、モデルの値、履歴スタックのすべてが、この契約を満たす必要があります。

同じ変更処理を使って逆操作を登録する

テストターゲットにファイルを追加し、サンプルのクラス名を置き換えれば実行できます。初期値は変更処理を呼ばず、直接ゼロに設定します。そうしないと、初期化まで取り消し可能な操作になってしまいます。変更処理で保存するのは変更前の値であり、後から対象の現在値を読み取るわけではありません。

import Foundation
import XCTest

@MainActor
final class PositionEditor {
    private(set) var position = 0
    let history = UndoManager()

    init() {
        history.groupsByEvent = false
    }

    func setPosition(_ next: Int) {
        guard next != position else { return }
        let previous = position
        history.registerUndo(withTarget: self) { target in
            target.setPosition(previous)
        }
        position = next
    }
}

final class PositionHistoryTests: XCTestCase {
    @MainActor
    func testGroupedDragCanUndoAndRedo() {
        let editor = PositionEditor()
        let history = editor.history

        history.beginUndoGrouping()
        editor.setPosition(10)
        editor.setPosition(20)
        history.endUndoGrouping()

        XCTAssertEqual(history.groupingLevel, 0)
        XCTAssertEqual(editor.position, 20)
        XCTAssertTrue(history.canUndo)

        history.undo()
        XCTAssertEqual(editor.position, 0)
        XCTAssertTrue(history.canRedo)

        history.redo()
        XCTAssertEqual(editor.position, 20)
        XCTAssertFalse(history.canRedo)
    }
}

取り消し時には、グループ内の操作が逆順に実行されます。まず十に戻り、次にゼロに戻ります。コールバックが再び同じ変更処理を通ることで、UndoManager は取り消し処理中に登録された新たな逆操作を、やり直しスタックに積みます。そのため、「再帰を避けるため」に直接代入する処理へ変えてはいけません。値は元に戻っても、やり直しの記録が失われてしまいます。

このサンプルのコールバックは、メインアクター上の履歴操作によって同期的に呼び出されます。実際の画面に組み込んだ後も、この呼び出しの境界を維持してください。コールバックを非同期タスクにすると、逆操作を登録する時点では、すでに取り消し処理が終わっている可能性があります。

履歴の分岐をアサーションで検証する

上の正常系テストを残したうえで、独立したテストを 3 つ追加します。各テストでモデルを新しく作り、前のテストケースが残した履歴スタックを共有しないようにします。

1 つ目は、1 グループを完了して取り消した後、新しいグループを開始して別の値を書き込みます。新しい値が反映され、canRedo が偽であることをアサートします。新しい編集によって履歴が分岐したため、破棄された経路をやり直せてはいけません。

2 つ目は、続けて 2 つのグループを作り、それぞれで位置を調整します。1 回の取り消しでは、2 つ目のグループの開始前までしか戻らず、初期状態まで一気に戻らないことを確認します。もう 1 回取り消して初めて初期値に戻ります。これにより、ボタンから呼ばれる処理でグループを閉じ忘れている問題を検出できます。

3 つ目は、現在と同じ値を設定します。サンプルの早期リターンによって、有効な履歴が追加されないことを保証します。新しく作ったモデルに同じ値を設定した後、canUndo が偽であることをアサートしてください。値が変わっていないことだけを確認すると、何も変更しない履歴操作によって、ボタンが誤って有効になる可能性を見逃します。

アサーションは最終結果だけでなく、各操作の直後に置きます。誤った操作を 2 回行った結果、偶然正しい最終値に戻ることもあるためです。

文書のライフサイクルと非同期処理の境界を管理する

文書を切り替えるときは、新しい文書用にモデルと UndoManager を新規作成する方法を優先します。インスタンスを再利用しなければならない場合は、進行中の編集操作を終えてから履歴を消去し、新しい値を読み込みます。古い文書のコールバックが新しい文書を変更できない状態にしてください。取り消しのコールバックがまだ完了していない間に、対象を切り替えてはいけません。

長時間動作するエディタでは、levelsOfUndo を設定して履歴グループ数を制限できます。ただし、これはメモリ使用量をバイト単位で制限するものではありません。画像全体や大きな配列を変更前の値として保存する場合は、各グループが保持するデータ量を別途評価する必要があります。

非同期計算のために、ユーザーのジェスチャーをまたいでグループを開いたままにしてはいけません。計算を開始するときに文書の識別子とリビジョン番号を記録し、結果が返ってきたら、まず両方が引き続き一致しているかを確認します。古くなった結果は破棄します。検証を通過した結果は、メインアクター上でグループを開始し、変更を適用してから終了する、ひとまとまりの処理として反映します。

このテストでは、スリープで待つ必要はありません。テストダブルに完了コールバックを一時保存させ、文書を切り替えるか編集を続けた後で、明示的に呼び出します。そのうえで、古い結果が状態にも履歴にも影響していないことをアサートします。

クラウドで実行し、画面の動作も確認する

モデルのテストを既存の iOS テストターゲットに追加し、起動済みシミュレータのデバイス識別子を指定して実行します。プロジェクトのパスと Scheme は、リポジトリで使っている実際の値に置き換えてください。

set -euo pipefail
: "${SIMULATOR_UDID:?Set SIMULATOR_UDID}"
mkdir -p artifacts
RESULT_PATH="artifacts/undo-$(uuidgen).xcresult"
xcodebuild test \
  -project EditorApp.xcodeproj \
  -scheme EditorApp \
  -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
  -only-testing:EditorAppTests/PositionHistoryTests \
  -resultBundlePath "$RESULT_PATH"

結果の保存先を毎回一意にすることで、再実行時に前の実行結果が上書きされるのを防ぎます。失敗したら、値の復元、グループの境界、やり直しの可否のどこが期待と異なるかを先に調べてください。履歴をすべて消去して問題を隠してはいけません。

最後に、画面での確認を 1 項目追加します。ドラッグ後に取り消しを実行し、キャンバス上の位置とボタンの状態を確認します。やり直した後にも、もう一度確認してください。モデルのテストは通るのにキャンバスが更新されない場合は、履歴を登録し直すのではなく、変更通知と画面のバインディングを調べます。モデルの契約が安定したら、実機で連続したジェスチャーのグループ境界がユーザーの期待どおりになっているかを確認します。

よくある質問

取り消しの処理中にも逆操作を登録する必要がありますか?

必要です。同じ変更メソッドで古い値に戻す際、置き換える直前の値を登録します。取り消し中の登録は UndoManager がやり直し用の履歴に移します。単なる代入では対応するやり直し操作が作られません。

シミュレータだけで取り消しジェスチャーを検証できますか?

十分ではありません。モデルテストでグループ化と状態復元を、画面テストでボタンを確認できますが、ジェスチャーの使いやすさや連続ドラッグの操作境界は実機でも確認します。

ビルドキューを専用クラウドMacに

物理ノード、機種、利用期間を選ぶだけで、ご自身専用のmacOS環境でXcodeのビルドや自動化タスクを実行できます。実際の利用可能状況は、コンソールに表示される最新情報をご確認ください。

ビルド環境を引き続き確認

機種の仕様と操作ガイドを確認して、次の設定を決めましょう。