개발자 컴퓨터에서는 통과하던 UI 테스트가 클라우드 Mac에서 수십 번 연속 실행된 뒤 간헐적으로 온보딩 화면에 멈춘다면, 원인은 대개 시뮬레이터 성능이 아니라 시작 상태가 계약으로 정의되지 않았기 때문이다. 한 테스트는 -skipOnboarding을 전달하고, 다른 테스트는 환경 변수를 사용하며, 세 번째 테스트는 영구 데이터를 정리하지 않는다. 각각 단독으로 실행할 때는 문제가 없어 보여도 병렬 실행하거나 순서를 바꾸면 서로의 상태를 오염시키기 시작한다. 해결하려면 인수 이름, 값 형식, 상태 초기화, Release 제외 조건을 하나의 테스트 가능한 인터페이스로 다뤄야 한다.
흩어진 문자열보다 시작 계약을 먼저 정의하기
시작 인수는 불리언 플래그에 적합하고, 환경 변수는 값이 있는 설정에 적합하다. 둘 다 한곳에서 선언해 테스트 타깃과 앱 타깃이 서로 다른 철자를 사용하지 않도록 해야 한다. 아래 파서는 Debug 빌드에서만 동작하며, 값이 없거나 빈 값인 경우에도 명확한 기본 동작을 적용한다.
#if DEBUG
enum UITestLaunchContract {
static let arguments = ProcessInfo.processInfo.arguments
static let environment = ProcessInfo.processInfo.environment
static var enabled: Bool {
arguments.contains("-ui-testing")
}
static var resetState: Bool {
arguments.contains("-reset-state")
}
static var fixture: String? {
guard enabled else { return nil }
return environment["UITEST_FIXTURE"]?.trimmingCharacters(
in: .whitespacesAndNewlines
).nonEmpty
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
#endif
계약은 최소한 네 가지 질문에 답해야 한다. 플래그 이름은 무엇인지, 값은 어디에서 읽는지, 유효하지 않은 값을 어떻게 처리하는지, 테스트가 아닌 일반 실행에서는 어떤 일이 일어나는지다. “값이 전달되지 않음”이 특정 비즈니스 상태를 암묵적으로 의미하게 해서는 안 된다. 또한 액세스 자격 증명, 서명 자료, 토큰을 환경 변수에 넣지 않아야 한다. 이런 정보는 프로세스 정보, 테스트 보고서 또는 실패 로그에 노출될 수 있다.
테스트 진입점의 목적은 재현 가능한 상태를 만드는 것이지, 비즈니스 검증을 우회하는 것이 아니다. 공개 인터페이스를 통해 확인할 수 있는 검증은 계속 공개 인터페이스를 사용해야 하며, 시작 계약에는 픽스처 초기화와 잔여 데이터 정리만 포함하는 것이 적절하다.
앱 시작 초기에 상태 격리 완료하기
상태 초기화는 사용자 환경설정을 읽고, 데이터베이스 컨테이너를 만들고, 첫 화면을 결정하기 전에 수행해야 한다. UI를 먼저 구성한 뒤 비동기로 정리하면 테스트가 이전 상태와 새 상태를 동시에 관찰하게 되어 재현하기 어려운 경쟁 조건이 생긴다.
테스트가 소유한 데이터만 정리하기
경계 없이 디렉터리를 삭제하는 대신 UI 테스트 전용 suite를 사용한다. 데이터베이스도 테스트 전용 컨테이너에 저장하고 영구 저장소 스택을 만들기 전에 제거해야 한다.
#if DEBUG
func prepareUITestState() throws {
guard UITestLaunchContract.enabled else { return }
if UITestLaunchContract.resetState {
let defaults = UserDefaults(suiteName: "com.example.app.uitests")
defaults?.removePersistentDomain(
forName: "com.example.app.uitests"
)
let storeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("UITestStore.sqlite")
if FileManager.default.fileExists(atPath: storeURL.path) {
try FileManager.default.removeItem(at: storeURL)
}
}
}
#endif
정리 함수는 반복 실행해도 안전해야 한다. 대상이 없을 때는 오류를 내지 않고, 삭제에 실패하면 일부만 정리된 상태로 계속 진행하지 말고 테스트 시작을 즉시 중단해야 한다. 픽스처 이름도 empty-project, three-items처럼 허용 목록에 매핑해야 하며, 환경 변수 값이 임의의 파일 경로로 직접 사용되게 해서는 안 된다.
XCTest에서 시작 인수 일관되게 조립하기
테스트 측에서는 하나의 시작 팩터리만 호출한다. 이 팩터리가 기존 프로세스를 종료하고, 고정 인수를 설정하며, 픽스처를 검증한다. 개별 테스트 메서드에서는 더 이상 launchArguments를 직접 수정하지 않는다.
final class AppLauncher {
static func launch(fixture: String) -> XCUIApplication {
let allowed = ["empty-project", "three-items"]
precondition(allowed.contains(fixture))
let app = XCUIApplication()
if app.state != .notRunning {
app.terminate()
}
app.launchArguments = [
"-ui-testing",
"-reset-state",
"-disable-animations"
]
app.launchEnvironment = [
"UITEST_FIXTURE": fixture,
"LC_ALL": "en_US_POSIX"
]
app.launch()
return app
}
}
launchArguments는 배열 전체를 한 번에 할당해야 하며, 공유 앱 인스턴스에 연속으로 append해서는 안 된다. 전체 할당을 사용하면 재실행할 때 이전 테스트가 남긴 플래그가 섞이지 않는다. 테스트에 서로 다른 두 상태가 필요하다면 실행 중에 환경 변수를 변경하지 말고 프로세스를 종료한 뒤 팩터리를 다시 호출해야 한다. 프로세스가 시작된 후에는 테스트 스크립트가 새 값을 설정해도 ProcessInfo가 다시 초기화되지 않는다.
계약 오류를 파이프라인 실패로 전환하기
코드 리뷰만으로는 오래된 인수를 완전히 차단할 수 없다. 소규모 단위 테스트를 추가해 인수 이름이 고유한지, 모든 픽스처를 파싱할 수 있는지 검증할 수 있다. UI 스모크 테스트에서는 인수 없이 정상 시작하는 경로, 유효한 픽스처로 예상 첫 화면에 진입하는 경로, 유효하지 않은 픽스처가 명확히 실패하는 경로를 모두 확인해야 한다.
클라우드 Mac에서 실행하기 전에 scheme, 테스트 계획, 시뮬레이터 식별자를 고정한다.
set -euo pipefail
: "${SIMULATOR_ID:?SIMULATOR_ID is required}"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan CI \
-destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
-resultBundlePath artifacts/LaunchContract.xcresult
파이프라인에서 “한 번 더 실행”하는 방식으로 최초 실패를 가려서는 안 된다. 최초 실패의 결과 번들은 원본 그대로 보존해야 한다. 재시도는 진단 단계로만 수행하고 새로운 출력 경로를 사용해야 한다. 그렇지 않으면 두 번째 실행의 성공 결과가 가장 중요한 최초 시작 실패 현장을 덮어쓴다.
Release 컴파일 조건 확인하기
테스트 진입점에 #if DEBUG를 사용했다면 Release에 DEBUG 또는 사용자 정의 테스트 조건이 잘못 포함되지 않았는지도 확인해야 한다.
set -euo pipefail
settings="$(
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings
)"
if printf '%s\n' "$settings" |
grep -E 'SWIFT_ACTIVE_COMPILATION_CONDITIONS.*(DEBUG|UI_TESTING)'; then
echo "Unexpected test compilation condition in Release" >&2
exit 1
fi
이 검사는 프로젝트 파일만 살펴보는 것보다 신뢰할 수 있도록 최종 해석된 빌드 설정을 대상으로 한다. 팀에서 여러 xcconfig를 사용한다면 배포 가능한 각 configuration에 대해 별도로 실행해야 한다.
병렬 실행과 실패 증거 처리하기
테스트를 병렬로 실행할 때는 각 worker에 독립적인 데이터 식별자가 있어야 한다. 파이프라인에서 민감하지 않은 UITEST_RUN_ID를 생성하고 이를 조합해 임시 디렉터리와 suite 이름을 만들 수 있다. 여러 worker가 고정된 데이터베이스 파일을 공유해서는 안 된다. 테스트가 끝나면 현재 실행 식별자에 해당하는 디렉터리만 삭제해 한 작업이 다른 작업의 실패 현장을 정리하지 않도록 해야 한다.
실패 증거에는 최소한 시작 인수 허용 목록, 픽스처 이름, 테스트 메서드, 시뮬레이터 식별자, 결과 번들 경로를 보존해야 한다. 환경 변수 사전 전체를 로그에 출력해서는 안 된다. 파이프라인에서 주입한 민감한 값이 섞여 있을 수 있기 때문이다. 허용된 키만 기록하고 값은 유형에 따라 마스킹하는 것이 좋다.
문제 해결 순서는 다음과 같이 고정할 수 있다.
- 앱이 시작 전에 실제로 종료되었는지 확인한다.
- 인수가 전체 할당되었고 중복 키가 없는지 확인한다.
- 상태 정리가 데이터베이스와 루트 UI 초기화보다 먼저 수행되는지 확인한다.
- 실패한 테스트와 직전 테스트가 사용한 데이터 디렉터리를 비교한다.
- 동일한 결과 번들에서 충돌, 어설션, 스크린샷 타임라인을 확인한다.
- 테스트 인수 없이 한 번 콜드 스타트를 수행해 일반 진입 경로가 영향을 받지 않았는지 확인한다.
출시 전 최소 승인 체크리스트
병합하기 전에는 다음 조건을 모두 충족해야 한다. 인수 이름이 중앙에서 정의되어 있고, 값이 있는 설정에는 허용 목록이 있으며, 테스트 데이터는 독립된 컨테이너에 저장되고, 정리 작업은 반복 실행할 수 있어야 한다. 시작할 때마다 인수 전체를 덮어쓰고, 병렬 worker끼리 디렉터리를 공유하지 않으며, 실패 결과 번들은 재시도로 덮어쓰지 않아야 한다. Release 컴파일 조건에는 테스트 플래그가 없어야 하고, 인수 없는 콜드 스타트는 정상 흐름으로 진입할 수 있어야 한다.
이러한 제약의 가치는 시작 팩터리를 하나 더 작성하는 데 있지 않다. “앱이 어떤 상태로 시작하는가”를 테스트 스크립트의 암묵적 합의에서 앱과 파이프라인이 모두 검증할 수 있는 인터페이스로 바꾸는 데 있다. 상태를 기술하고, 거부하고, 정리할 수 있어야 클라우드 Mac에서 실행 순서를 바꾸거나 병렬로 실행하거나 장기간 반복 실행해도 서로 비교 가능한 결과를 얻을 수 있다.
자주 묻는 질문
각 UI 테스트에서 시작 인수를 직접 추가하면 왜 문제가 되나요?
문자열이 분산되면 철자 차이, 인수 순서 의존성, 폐기된 플래그 잔류가 생깁니다. 하나의 타입에서 이름과 값 형식, 기본 동작을 정의해 앱과 테스트가 공유해야 합니다.
테스트 훅이 Release 빌드에 들어가지 않았는지 어떻게 확인하나요?
구현을 DEBUG 조건부 컴파일 안에 두고 CI에서 Release 컴파일 조건과 빌드 설정을 검사합니다. 테스트 인수 없는 콜드 스타트 검사도 함께 실행합니다.
검증된 워크플로를 독점 물리 노드에서 실행하세요
작업에 맞춰 M4 또는 M4 Pro, 대상 노드와 결제 주기를 선택한 다음 주문 전에 구성과 추가 옵션을 확인하세요.