ローカルでは問題なく取り消せるエディタでも、クラウド 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のビルドや自動化タスクを実行できます。実際の利用可能状況は、コンソールに表示される最新情報をご確認ください。