iOS 실행 취소와 다시 실행을 작업 단위로 테스트하기

iOS 실행 취소와 다시 실행을 작업 단위로 테스트하기

로컬에서는 실행 취소가 잘 되던 편집기도 클라우드 Mac에서 테스트하면 “한 번 드래그한 것을 되돌리려면 실행 취소를 열 번 눌러야 하는” 상황이나 “실행 취소는 되지만 다시 실행은 안 되는” 상황이 생길 수 있습니다. 버튼 로직부터 덧붙이지 말고 편집 작업, 이력 관리, 화면 갱신을 분리한 뒤 사용자 작업 하나가 실제로 몇 개의 이력 그룹에 대응하는지 확인하세요. 아래에서는 캔버스 좌표 대신 정수 위치를 사용해 iOS 단위 테스트 타깃에 바로 넣을 수 있는 최소 동작 계약을 정의합니다.

실행 취소의 작업 단위부터 정하기

입력을 세 가지로 나눕니다. 버튼으로 한 번 조정하기, 연속해서 드래그하기, 프로그램에서 문서 불러오기입니다. 버튼 조정 한 번은 하나의 그룹으로 묶고, 드래그는 시작부터 끝까지 하나의 그룹으로 묶습니다. 문서 불러오기는 편집 이력에 넣지 않습니다. 위치 콜백이 호출될 때마다 별도의 사용자 작업이 발생한 것으로 취급해서는 안 됩니다.

UndoManager는 기본적으로 이벤트 루프에 따라 작업을 자동으로 그룹화할 수 있습니다. 테스트가 이 동작에 의존하면 실행 시점이 달라지는 것만으로도 그룹 경계가 바뀔 수 있습니다. 여기서는 groupsByEvent를 끄고 편집 로직의 진입점에서 그룹 시작과 종료를 명시적으로 짝지어 호출합니다. 실제 서비스의 UI도 같은 전략을 사용해야 합니다. 테스트에서만 이 설정을 꺼서는 안 됩니다.

모든 모델 변경과 이력 작업은 메인 액터에서 실행합니다. 테스트를 시작하기 전에 groupingLevel이 0인지 확인하고, 아직 닫히지 않은 그룹 안에서 실행 취소를 호출하지 마세요. 드래그를 취소했을 때 시작 위치로 돌아갈지 현재 위치를 유지할지는 먼저 제품 동작 규칙으로 정한 다음, 그에 맞는 처리 경로를 구현해야 합니다.

검증할 것은 “버튼을 누를 수 있다”가 아니라 “실행 취소 한 번으로 작업 전 상태로 돌아가고, 다시 실행 한 번으로 작업 후 상태로 돌아간다”입니다. 화면 표시, 모델 값, 이력 스택이 모두 이 계약을 동시에 충족해야 합니다.

같은 진입점에서 역방향 작업 등록하기

테스트 타깃에 새 파일을 추가하고 예제의 클래스 이름을 바꾸면 실행할 수 있습니다. 초기값은 변경 메서드를 호출하지 않고 직접 0으로 설정합니다. 그렇지 않으면 초기화도 실행 취소할 수 있는 작업이 됩니다. 변경 메서드에서는 나중에 대상의 현재 값을 읽는 것이 아니라, 변경 직전의 값을 저장해야 합니다.

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

실행 취소 시 그룹 안의 작업은 역순으로 실행됩니다. 먼저 10으로 돌아간 뒤 0으로 돌아갑니다. 콜백은 다시 변경 메서드를 거치며, UndoManager는 실행 취소 단계에서 새로 등록되는 역방향 작업을 다시 실행 스택에 넣습니다. 따라서 “재귀를 피하겠다”는 이유로 직접 값을 대입하는 방식으로 바꾸면 안 됩니다. 그렇게 하면 값은 복원되지만 다시 실행에 필요한 기록은 사라집니다.

예제의 콜백은 메인 액터에서 수행되는 이력 작업에 의해 동기적으로 호출됩니다. 실제 UI에 연결한 뒤에도 이 호출 경계를 유지해야 합니다. 콜백을 비동기 작업으로 넘기면 등록 시점에는 이미 실행 취소 단계가 끝나 있을 수 있습니다.

이력 분기를 검증문에 반영하기

위의 정상 동작 테스트를 유지하고, 독립적인 테스트 세 개를 추가합니다. 각 테스트는 새 모델을 만들어야 하며, 이전 테스트가 남긴 이력 스택을 공유해서는 안 됩니다.

첫째, 하나의 그룹을 완료한 뒤 실행 취소하고, 새 그룹을 시작해 다른 값을 설정합니다. 새 값이 반영되었고 canRedo가 거짓인지 검증합니다. 새 편집으로 이력이 분기되었으므로 이미 버린 경로를 다시 실행할 수 있어서는 안 됩니다.

둘째, 두 개의 그룹을 연속으로 만들고 각각 위치를 조정합니다. 실행 취소 한 번으로는 두 번째 그룹이 시작되기 전 상태까지만 돌아가야 하며, 곧바로 초기 상태로 돌아가면 안 됩니다. 한 번 더 실행 취소해야 초기값이 복원되어야 합니다. 이 테스트로 버튼 처리 진입점에서 그룹 종료를 빠뜨린 문제를 찾아낼 수 있습니다.

셋째, 현재 값과 같은 값을 전달합니다. 예제의 조기 반환은 유효한 이력이 추가되지 않도록 보장해야 합니다. 새 모델에서 같은 값으로 변경을 시도한 뒤 canUndo가 거짓인지 검증합니다. 값이 그대로인지 확인하는 것만으로는 부족합니다. 빈 이력 작업이 남아 버튼이 잘못 활성화될 수도 있기 때문입니다.

최종 결과만 확인하지 말고 각 작업 직후에 검증문을 배치하세요. 잘못된 작업 두 번이 우연히 값을 올바른 최종 상태로 되돌릴 수도 있습니다.

문서와 비동기 처리의 경계 정리하기

문서를 전환할 때는 새 문서용 모델과 UndoManager를 새로 만드는 방식을 우선합니다. 인스턴스를 반드시 재사용해야 한다면 먼저 현재 편집 작업을 끝내고, 이력을 지운 다음 새 값을 불러오세요. 이전 문서의 콜백이 새 문서를 변경할 수 없도록 해야 합니다. 실행 취소 콜백이 아직 끝나지 않은 상태에서 대상을 바꾸면 안 됩니다.

오랫동안 실행되는 편집기는 levelsOfUndo로 이력 그룹 수를 제한할 수 있습니다. 다만 이 설정은 메모리 사용량의 바이트 단위 상한이 아닙니다. 이미지 전체나 큰 배열을 이전 값으로 저장한다면, 각 그룹이 보관하는 데이터의 양도 따로 평가해야 합니다.

비동기 계산을 위해 사용자 제스처가 끝난 뒤까지 그룹을 열린 상태로 유지해서는 안 됩니다. 계산을 시작할 때 문서 식별자와 리비전 번호를 기록하고, 결과가 돌아오면 두 값이 여전히 일치하는지 먼저 확인합니다. 유효하지 않은 오래된 결과는 버립니다. 검증을 통과한 결과만 메인 액터에서 새 그룹을 시작하고, 값을 변경한 뒤 그룹을 종료하는 하나의 완결된 작업으로 처리합니다.

이 동작을 테스트할 때는 sleep으로 기다리지 않습니다. 테스트 대역이 완료 콜백을 임시로 보관하게 하고, 문서를 전환하거나 편집을 더 진행한 다음 해당 콜백을 직접 호출하세요. 이전 결과가 상태와 이력 어느 쪽에도 영향을 주지 않았는지 검증합니다.

클라우드에서 실행하고 UI 동작 검증하기

모델 테스트를 기존 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"

고유한 결과 경로를 사용하면 반복 실행 시 이전 테스트 결과를 덮어쓰지 않습니다. 실패했을 때는 값 복원, 그룹 경계, 다시 실행 가능 여부 중 무엇이 계약과 다른지 먼저 확인하세요. 전체 이력을 지우는 방식으로 오류를 가려서는 안 됩니다.

마지막으로 UI 검증 항목을 하나 추가합니다. 드래그 후 실행 취소하고 캔버스의 위치와 버튼 상태를 확인한 다음, 다시 실행한 뒤에도 같은 항목을 확인합니다. 모델 테스트는 통과하지만 캔버스가 갱신되지 않는다면 이력을 다시 등록할 것이 아니라 변경 알림과 UI 바인딩을 점검해야 합니다. 모델의 동작 계약이 안정되면 실제 기기에서 연속 제스처의 그룹 경계가 사용자 기대에 맞는지 확인합니다.

자주 묻는 질문

실행 취소 콜백에서도 역연산을 등록해야 하나요?

네. 같은 변경 메서드로 이전 값을 복원하면서 복원 직전의 값을 등록해야 합니다. 실행 취소 중에는 UndoManager가 이 등록을 다시 실행 기록으로 보냅니다. 직접 대입만 하면 대응하는 다시 실행 작업은 만들어지지 않습니다.

시뮬레이터 테스트로 실행 취소 제스처 검증을 대체할 수 있나요?

아니요. 모델 테스트는 그룹과 상태 복원을, 시뮬레이터 UI 테스트는 버튼 동작을 검증할 수 있습니다. 제스처 사용성과 연속 드래그의 작업 경계는 실제 기기에서도 확인해야 합니다.

빌드 대기열을 전용 클라우드 Mac에 맡기세요

물리 노드, 모델, 요금제를 선택하면 Xcode 빌드와 자동화 작업을 전용 macOS 환경에서 실행할 수 있습니다. 실제 사용 가능 여부는 콘솔에 표시되는 실시간 상태를 확인하세요.

빌드 환경을 계속 확인하세요

모델 사양과 사용 안내를 비교해 다음 설정을 결정하세요.