Undo works in the editor locally, but testing on a cloud Mac reveals that “one drag takes ten undo operations” or “undo succeeds, but redo is unavailable.” Before adding more button logic, separate editing actions, history tracking, and UI updates, then verify how many history groups a single user action creates. Below, we use an integer position instead of canvas coordinates to define a minimal contract you can test in an iOS unit test target.
Define the unit of undo first
Identify three kinds of input: a single button adjustment, a continuous drag, and programmatic document loading. A button adjustment creates one group; a drag creates one group from start to finish; loading a document does not enter the editing history. Do not treat every position callback as a separate user action.
By default, UndoManager can group operations automatically by event-loop cycle. If a test depends on that behavior, changes in execution timing can shift group boundaries. Here, we disable groupsByEvent and explicitly pair group starts and ends at the application’s editing entry points. The production UI must use the same strategy; disabling it only in tests is not enough.
Keep all model mutations and history operations on the main actor. Before starting a test, confirm that groupingLevel is zero, and do not undo while a group is still open. Whether canceling a drag restores its starting position or keeps its current position should be a product rule first, with the corresponding implementation added afterward.
The acceptance criterion is not “the button can be clicked,” but “one undo restores the state before the action, and one redo restores the state after it.” The UI, model value, and history stack must all satisfy this contract.
Register inverse operations through the same editing entry point
Add a file to the test target; replace the example class names as needed to run it. Set the initial value directly to zero without calling the editing method. Otherwise, initialization becomes an undoable action too. The editing method must capture the value before the change, rather than read the target’s current value later.
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)
}
}
During undo, operations within the group run in reverse order: the position first returns to ten, then to zero. The callback goes through the editing method again, and UndoManager places the newly registered inverse operations on the redo stack while undoing. Do not replace that call with a direct assignment to “avoid recursion.” That would restore the data but lose the redo records.
In this example, history operations on the main actor invoke the callback synchronously. Preserve that call boundary when connecting the real UI. Do not turn the callback into an asynchronous task, or the undo phase may already be over when the inverse operation is registered.
Assert history branching behavior
Keep the successful case above, then add three independent tests. Create a new model for each test; do not share a history stack left over from a previous case.
First, complete a group and undo it, then open a new group and write a different value. Assert that the new value takes effect and canRedo is false. The new edit has created a history branch, so the abandoned path must no longer be available for redo.
Second, create two consecutive groups, adjusting the position in each. One undo must return to the state before the second group, not directly to the initial state. Only the second undo should restore the initial value. This catches button handlers that forget to end a group.
Third, submit the same value. The early return in the example should ensure that no new effective history entry is added. Perform a same-value change on a fresh model and assert that canUndo is false. Checking only that the value stayed unchanged is not enough: an empty history action could still incorrectly enable the button.
Place assertions after every operation, not just at the end. Two incorrect operations can still happen to restore the expected final value.
Keep document and asynchronous boundaries under control
When switching documents, prefer creating a new model and UndoManager for the new document. If you must reuse an instance, first finish the current editing action, then clear the history and load the new value. Ensure that callbacks from the old document cannot modify the new one. Do not switch objects while an undo callback is still running.
A long-running editor can set levelsOfUndo to limit the number of history groups, but this is not a memory limit in bytes. If you retain an entire image or a large array as the previous value, you still need to assess how much data each group holds.
Do not keep a group open across a user-gesture boundary while asynchronous work runs. When starting a calculation, record the document identifier and revision number. When the result arrives, first check that both still match; discard stale results. For a result that passes validation, begin a group, apply the change, and end the group entirely on the main actor.
The corresponding test should not rely on sleeps. Have a test double hold the completion callback, then explicitly invoke it after switching documents or making further edits. Assert that the old result has affected neither the state nor the history.
Run in the cloud and verify the UI
Add the model tests to the existing iOS test target and run them using the device identifier of an already booted simulator. Replace the project path and Scheme with the actual values from your repository.
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"
A unique result path prevents repeated runs from overwriting earlier evidence. If a test fails, first determine whether the mismatch is in value restoration, group boundaries, or redo availability. Do not hide the problem by clearing all history.
Finally, add a UI acceptance check: undo after a drag and inspect the canvas position and button states, then redo and check them again. If the model tests pass but the canvas does not update, investigate change notifications and UI bindings rather than registering history again. Once the model contract is stable, use a physical device to confirm that group boundaries for continuous gestures match user expectations.
Frequently asked questions
Should an undo handler register another inverse operation?
Yes. Restoring the old value through the same mutation method registers the value being replaced. During undo, UndoManager places that registration on the redo stack. Direct assignment restores data but does not create the matching redo action.
Can simulator tests replace testing undo gestures on a device?
No. Model tests verify grouping and state restoration, while simulator UI tests can verify buttons. Gesture usability and the boundaries of a continuous drag still need device testing.
Hand Off Build Queues to Your Dedicated Cloud Mac
Choose a physical node, Mac model, and billing term to run Xcode builds and automation in your own macOS environment. Check the console for real-time availability.