같은 iOS 설정 테스트가 첫 실행에서는 통과했는데, 두 번째 실행에서는 이전에 남겨 둔 테마 값을 읽는 경우가 있습니다. 클라우드 Mac으로 옮기면 결과가 또 달라지기도 합니다. 대개는 머신 성능 문제가 아니라, 테스트에서 프로세스 내부의 기본값과 저장된 설정, 실행 인수를 뒤섞었기 때문입니다. VpsKeep에서 이런 작업을 실행할 때는 먼저 설정 저장소를 격리한 뒤 비즈니스 로직의 동작을 검증하세요. 시뮬레이터 전체를 초기화하고 운에 맡겨서는 안 됩니다.
먼저 설정 읽기의 계약을 명확히 정의하기
UserDefaults는 단일 키-값 테이블이 아닙니다. 값을 읽을 때 여러 도메인을 검색합니다. 실행 인수는 앱 도메인의 값보다 우선할 수 있고, 등록 도메인은 대체 기본값을 제공합니다. 따라서 object(forKey:)로 어떤 값을 읽었다고 해서 그 값이 저장되어 있다는 뜻은 아닙니다.
각 설정에 대해 먼저 키 이름, 허용되는 타입, 값이 없을 때의 반환값을 명확히 정하세요. 예를 들어 테마 키는 system, light, dark만 허용하고, 값이 없거나 타입이 잘못되었거나 알 수 없는 문자열이면 모두 system을 반환하도록 합니다. 이렇게 하면 이전 버전이 남긴 잘못된 값도 일관되게 처리할 수 있습니다.
register(defaults:)는 현재 프로세스에서 사용할 대체 기본값만 등록하며, 이를 사용자 설정으로 저장하지는 않습니다. 영구 저장된 값을 삭제한 뒤 다시 기본값이 읽히는 것은 정상적인 기본값 복귀이지, 삭제 실패가 아닙니다.
dictionaryRepresentation()으로 특정 키가 저장되었다고 판단하지 마세요. 이 메서드는 현재 읽을 수 있는 설정을 모아 반환하므로 등록 도메인의 값도 포함될 수 있습니다. 저장 여부를 확인하려면 해당 영구 도메인을 직접 읽어야 합니다.
독립 suite를 주입하고 실제 설정은 건드리지 않기
프로덕션 코드는 기본적으로 .standard를 사용해도 되지만, 테스트에서는 반드시 전용 인스턴스를 주입해야 합니다. 설정 래퍼가 메서드 내부에서 .standard를 다시 가져오게 해서는 안 됩니다. 그러면 의존성을 주입해도 겉으로만 격리된 셈입니다.
아래 코드는 iOS 단위 테스트 타깃에 넣을 수 있습니다. 각 테스트는 무작위 suite 이름을 사용하고, 자신이 만든 영구 도메인만 삭제합니다. 예제에서는 설정 읽기 래퍼도 테스트 파일에 함께 두었습니다. 실제 프로젝트에 적용할 때는 래퍼를 앱 타깃으로 옮기고 테스트에서 임포트하세요.
import Foundation
import XCTest
struct ThemePreferences {
let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: ["theme": "system"])
}
var theme: String {
guard let value = defaults.object(forKey: "theme") as? String,
["system", "light", "dark"].contains(value) else {
return "system"
}
return value
}
}
final class ThemePreferencesTests: XCTestCase {
private func withSuite(
_ body: (UserDefaults, String) throws -> Void
) throws {
let name = "preferences-tests." + UUID().uuidString
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defaults.removePersistentDomain(forName: name)
defer {
defaults.removePersistentDomain(forName: name)
}
try body(defaults, name)
}
func testOverrideAndRemoval() throws {
try withSuite { defaults, name in
let preferences = ThemePreferences(defaults: defaults)
XCTAssertEqual(preferences.theme, "system")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
defaults.set("dark", forKey: "theme")
XCTAssertEqual(preferences.theme, "dark")
XCTAssertEqual(
defaults.persistentDomain(forName: name)?["theme"]
as? String,
"dark"
)
defaults.removeObject(forKey: "theme")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
XCTAssertEqual(preferences.theme, "system")
}
}
func testInvalidValuesFallBack() throws {
try withSuite { defaults, _ in
let preferences = ThemePreferences(defaults: defaults)
defaults.set(["dark"], forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
defaults.set("unknown", forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
}
}
}
무작위 이름을 사용하면 병렬 테스트가 서로의 값을 덮어쓰는 일을 피할 수 있습니다. 종료 시 정리 코드는 정상적인 실행 경로를 처리하지만, 프로세스가 강제로 종료되면 defer가 실행된다는 보장은 없습니다. 남아 있는 무작위 도메인은 다음 실행에서 재사용되지 않습니다. 다만 장기간 사용하는 테스트 기기에서는 주기적으로 도메인을 점검하고, 테스트용으로 확인된 도메인만 정리해야 합니다.
값 덮어쓰기와 삭제의 경계 조건까지 검증하기
최소한 확인해야 할 항목은 저장값이 없을 때 기본값으로 복귀하는지, 유효한 값을 쓰면 기본값을 덮어쓰는지, 삭제 후 기본값으로 복귀하는지, 타입이 잘못되거나 알 수 없는 열거형 값일 때 기본값으로 복귀하는지입니다. 여기에 “비즈니스 로직의 설정 래퍼를 다시 생성해도 저장된 값을 읽는지”도 추가해야 합니다. 래퍼가 초기화될 때마다 set으로 사용자 선택을 초기화하는 문제를 막기 위해서입니다.
비즈니스 로직에서 공유 suite와 앱 도메인을 함께 사용한다면, 단위 테스트에서 무심코 addSuite(named:)를 호출하지 마세요. 이 메서드는 검색 경로를 바꾸므로 테스트 결과가 다른 도메인에 영향을 받게 됩니다. 먼저 공유 설정을 별도로 주입할 수 있는 의존성으로 분리해 각각 검증한 뒤, 앱 통합 테스트를 진행하세요.
불리언 설정도 “설정하지 않음”과 “사용자가 끄기를 선택함”을 구분해야 합니다. bool(forKey:)만 호출하면 값이 없는 경우에도 false를 반환하므로, 이 결과만으로는 사용자가 설정을 변경한 적이 있는지 알 수 없습니다. 키의 존재 여부를 명시적으로 확인하거나, 버전 정보를 갖춘 별도 상태 필드를 두어야 합니다.
두 프로세스로 실제 영속성 확인하기
같은 프로세스에서 UserDefaults 객체를 새로 만드는 것으로는 읽기 동작만 확인할 수 있을 뿐, 재시작 후에도 값이 유지되는지는 확인할 수 없습니다. 통합 테스트는 쓰기와 읽기의 두 단계로 나누고, 두 단계에서 동일한 고정 테스트 suite를 사용해야 합니다.
첫 단계에서는 테마를 저장하고 화면이나 테스트 인터페이스를 통해 다시 읽은 값을 확인한 뒤, 앱을 정상 종료합니다. 두 번째 단계에서는 앱을 실행하되 값을 다시 쓰지 않고, 테마가 복원되었는지 바로 확인합니다. 마지막으로 별도의 정리 단계를 실행해 해당 테스트 도메인을 삭제합니다.
프로세스를 강제로 종료한 직후 값을 읽는 방식으로 일반적인 저장 동작을 검증하지 마세요. 이는 비정상 종료 시의 경계 조건을 확인하는 테스트입니다. 정해진 몇 초를 기다리거나 synchronize()를 호출했다는 이유만으로 디스크 기록이 완료되었다고 판단해서도 안 됩니다.
실행 인수는 별도의 테스트 흐름으로 다뤄야 합니다. 앱이 인수로 테마를 주입한다면, 그 값이 다른 값을 덮어쓰는지 따로 검증하고 이후 실행에서는 해당 인수를 제거해야 합니다. 그렇지 않으면 인수가 이미 저장된 값을 가려 영속성 테스트가 잘못 통과할 수 있습니다.
실패 결과를 추적 가능한 기록으로 남기기
클라우드 Mac의 파이프라인에서는 테스트 이름, suite 이름, 앱 버전, 키 존재 여부, 실제 타입, 기대하는 대체 기본값만 기록하면 됩니다. 설정 딕셔너리 전체를 그대로 내보내지 마세요. 사용자 식별자나 내부 환경 정보가 들어 있을 수 있습니다.
두 차례 연속 실행한 다음 병렬 실행도 활성화해 결과가 일관되는지 확인하세요. 두 번째 실행에서만 실패한다면 고정 suite와 정리되지 않은 도메인을 먼저 찾으세요. 병렬 실행에서만 실패한다면 공유 싱글턴을 점검하고, 재시작 후에만 실패한다면 초기화 과정에서 사용자 값을 덮어쓰는지 확인하세요.
최종 통과 기준은 “어느 한 번 올바른 문자열을 읽었다”가 아닙니다. 기본값, 사용자 값의 우선 적용, 삭제 후 기본값 복귀, 프로세스 재시작 각각에 대한 독립적인 검증 근거가 있어야 합니다. 이 경계 조건을 명확히 정해 두어야 새 머신과 장기간 사용하는 노드에서도 설정 테스트가 같은 결론을 냅니다.
자주 묻는 질문
register(defaults:)가 기본값을 디스크에 저장하나요?
아니요. 현재 프로세스의 등록 도메인에 대체값을 추가합니다. 다음 실행에서도 등록해야 하며, 저장된 사용자 설정으로 취급하면 안 됩니다.
UserDefaults 객체를 다시 만들면 영속성을 확인할 수 있나요?
아니요. 같은 프로세스의 객체는 설정 상태를 공유할 수 있습니다. 쓰기 프로세스를 정상 종료한 뒤 새로운 프로세스에서 읽어야 합니다.
단계를 전용 클라우드 Mac에서 실행하세요
가상 머신이 아닌 Mac mini 한 대 전체를 임대하세요. 일·주·월·분기 단위로 기간을 선택하고, 모델과 리전을 확인한 뒤 주문을 확정하세요. 실제 이용 가능 여부는 콘솔에 표시되는 실시간 상태를 기준으로 합니다.