Xcode Cloud에서 React Native 테스트 실행: UITest target을 0부터 CLI로 만들기
RN 프로젝트는 기본적으로 네이티브 테스트 target이 없다. 검증된 경로: CLI의 xcodeproj gem으로 UITest target을 만든다 → 로컬에서 smoke 통과 → Xcode Cloud에 Test action 추가 → 클라우드에서 첫 실행 성공. 더불어 gem은 자동 설정하지 않고 Xcode GUI는 자동 설정하는 3개의 build settings 함정.
React Native 프로젝트가 Xcode Cloud에서 테스트를 돌릴 때 첫 번째 관문은 CI 설정이 아니라, 프로젝트에 네이티브 테스트 타깃이 아예 없다는 것이다. RN이 기본으로 생성하는 iOS 프로젝트에는 app 타깃 하나뿐이며, scheme의 Test Action조차 존재하지 않는 blueprint를 가리키고 있을 수 있다(고아 참조). 그래서 먼저 UI Test Bundle 타깃을 0부터 만들고, 로컬에서 smoke를 통과시킨 뒤, Xcode Cloud의 Test action이 그것을 돌리게 해야 한다.
이 글은 검증된 체인을 기록한 것이다: 명령줄에서 xcodeproj gem으로 UITest 타깃을 만들고(Xcode GUI는 클릭하지 않는다) → 로컬 smoke가 초록 → Xcode Cloud에 Test action 추가 → 클라우드에서 첫 빌드 통과. 그리고 타깃 생성 과정에서 gem은 자동으로 설정하지 않지만 Xcode GUI는 자동으로 설정하는 3개의 build settings 대함정도 함께.
먼저 진단: 테스트 타깃이 정말 있는지
"Xcode에 보인다"에 의존하지 마라. project.pbxproj를 직접 뒤진다:
# NativeTarget이 몇 개인지 확인
grep -c "isa = PBXNativeTarget" ios/YourApp.xcodeproj/project.pbxproj
# scheme의 TestAction이 무엇을 참조하는지 확인
grep -A2 "TestAction" ios/YourApp.xcodeproj/xcshareddata/xcschemes/*.xcschemeRN 프로젝트는 기본적으로 PBXNativeTarget 1개(app)뿐이지만, scheme의 TestAction에는 BlueprintName = "YourAppTests"가 걸려 있을 수 있다——해당 타깃은 sanitize되거나 마이그레이션 과정에서 삭제됐고 참조만 남은 것이다. 이것이 고아다: Xcode Cloud의 Test action이 돌면 test target을 찾을 수 없다고 에러를 낸다.
결론: 0부터 만든다.
왜 타깃을 명령줄로 만드는가 (Xcode GUI가 아닌)
GUI로 만드는 게 가장 안정적이지만, 재현할 수 없고 버전 관리도 안 된다. 팀의 두 번째 사람이나 다른 머신에서는 또 한 번 클릭해야 한다. xcodeproj gem은 "타깃 만들기"를 커밋 가능한 스크립트로 바꿔준다——멱등적이고 diff가 된다.
대가: gem으로 만든 타깃은 GUI가 자동으로 설정하는 build settings 몇 개를 자동으로 설정하지 않는다——이것이 뒤에 나올 3개 함정의 근원이다.
타깃 만들기 (xcodeproj gem 스크립트)
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/YourApp.xcodeproj')
app = project.targets.find { |t| t.name == 'YourApp' }
abort 'app target을 찾을 수 없음' unless app
test = project.new_target(:ui_test_bundle, 'YourAppUITests', :ios, '15.1')
test.add_dependency(app)
test.build_configurations.each do |c|
c.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.yourcompany.YourAppUITests'
c.build_settings['TEST_TARGET_NAME'] = 'YourApp' # UITest가 테스트할 app
c.build_settings['DEVELOPMENT_TEAM'] = 'YOUR_TEAM_ID'
c.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
c.build_settings['SWIFT_VERSION'] = '5.0'
end
# smoke 소스 파일 참조 (물리 파일은 먼저 만들어둘 것)
group = project.main_group.new_group('YourAppUITests', 'YourAppUITests')
file_ref = group.new_file('YourAppUITests.swift')
test.add_file_references([file_ref])
project.save
# scheme: test action = UITests, launch = app
scheme = Xcodeproj::XCScheme.new
scheme.add_build_target(test)
scheme.add_build_target(app)
scheme.add_test_target(test)
scheme.set_launch_target(app)
scheme.save_as('ios/YourApp.xcodeproj', 'YourAppUITests', true)핵심 포인트: TEST_TARGET_NAME은 피테스트 app을 가리킨다(UI test runner가 어떤 app을 띄울지 알아야 한다); :unit_test_bundle이 아니라 :ui_test_bundle을 쓴다(UI 테스트는 전자를 쓴다).
★ 큰 함정: gem이 자동으로 설정하지 않는 3개의 build settings
스크립트가 끝나고 pod install까지 마쳤는데 xcodebuild test 한 방에 함정에 부딪힌다. GUI로 타깃을 만들 때 Xcode가 자동으로 채워주는 이 3개를 gem은 채워주지 않는다.
### 함정 1 · Info.plist 누락 → 코드 서명 실패
Cannot code sign because the target does not have an Info.plist fileGUI로 타깃을 만들 때 Xcode는 GENERATE_INFOPLST_FILE = YES를 자동으로 설정한다(빌드 시 plist를 합성). gem은 설정하지 않아서 서명이 plist를 찾지 못하고 죽는다.
c.build_settings['GENERATE_INFOPLST_FILE'] = 'YES'
c.build_settings['CURRENT_PROJECT_VERSION'] = '1'
c.build_settings['MARKETING_VERSION'] = '1.0'### 함정 2 · RN codegen 스크립트가 test 타깃에 새어 들어감 → 산출물 충돌
error: Multiple commands produce '.../YourAppUITests-Runner.app/PlugIns/YourAppUITests.xctest'원인: React Native의 codegen이 타깃에 PBXShellScriptBuildPhase(시작 스크립트)를 끼워 넣는다. gem으로 만든 test 타깃이 이 phase를 상속받으면, test runner의 기본 산출물 경로와 충돌해 산출물이 중복된다.
수정: 타깃을 만든 뒤 거기 섞여 들어온 shell script build phase를 삭제한다:
test.shell_script_build_phases.each { |p| test.build_phases.delete(p) }또는 pbxproj에서 test 타깃 아래의 isa = PBXShellScriptBuildPhase 세그먼트를 직접 지운다.
### 함정 3 · PRODUCT_NAME 비어 있음 → .xctest 산출물명 오류
Multiple commands produce '.../PlugIns/.xctest' # 주의: .xctest 앞 이름이 비어 있음gem으로 만든 타깃은 PRODUCT_NAME을 명시적으로 설정하지 않을 수 있고, 그러면 산출물명이 비어 버린다.
c.build_settings['PRODUCT_NAME'] = '$(TARGET_NAME)' # 또는 명시적으로 'YourAppUITests'이 세 함정은 근원이 같다: Xcode GUI가 타깃을 만들 때 하는 "자동 뒷마무리"를 gem은 네 손에 맡긴다. xcodebuild test를 한 번 돌리고, 에러 메시지에 따라 하나씩 채워 넣는다——다 채우고 나면 안정된다.
smoke 테스트: app이 떠서 죽지 않는지
첫 smoke는 기능을 검사하지 않는다. 체인만 검증한다: app이 시뮬레이터에서 뜨고, 메인 화면에 들어가고, 죽지 않는지.
import XCTest
final class YourAppUITests: XCTestCase {
func testAppLaunches() throws {
let app = XCUIApplication()
app.launch()
let appeared = app.buttons.firstMatch.waitForExistence(timeout: 10)
|| app.staticTexts.firstMatch.waitForExistence(timeout: 5)
XCTAssertTrue(appeared, "app 시작 후 상호작용 가능한 요소가 있어야 함")
}
}왜 buttons.firstMatch || staticTexts.firstMatch를 쓰는가: RN 첫 화면은 순수 JS 렌더링일 수 있어서, 네이티브 층 첫 프레임엔 button이 없을 수 있지만 text는 반드시 있다. 10s+5s 이중 대기로 콜드스타트를 커버한다.
Podfile: test 타깃 블록
메인 타깃 블록 뒤에 추가한다:
target 'YourAppUITests' do
inherit! :search_paths
endinherit! :search_paths는 test 타깃이 메인 타깃의 검색 경로(headers / frameworks)만 상속하게 만들고, pods를 중복 link하지 않게 한다——그렇지 않으면 중복 심볼. 그 다음 cd ios && pod install.
로컬에서 통과
cd ios
xcodebuild test \
-workspace YourApp.xcworkspace \
-scheme YourApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:YourAppUITests TEST SUCCEEDED 가 나오면 로컬 체인 OK.
클라우드: Xcode Cloud에 Test action 추가
1. App Store Connect → 본인 app → Xcode Cloud → workflow(master/브랜치 감시) 2. Actions 구역에 Test action 추가(기존 Archive action은 유지) 3. 설정: - Scheme은 본인의 UITests scheme 선택(app scheme이 아니다——후자의 Test Action은 고아 참조라 돌릴 수 없다) - Destination: iPhone 시뮬레이터 - Environment: 우선 아무것도 추가하지 않는다(smoke만) 4. Save
⚠️ 핵심: Test action 추가 이전에 push로 촉발된 build는 Archive만 돈다. 반드시 Test action 추가 뒤에 새 build를 한 번 더 트리거해야 test가 돈다. 이미 in-flight인 build는 보충 실행되지 않는다.
결과
처음 Test action을 단 클라우드 build: TEST SUCCEEDED, Archive까지 포함해 총 약 7분. Xcode Cloud의 Test action은 XCTest / XCUITest(네이티브 층)만 돌리고 jest는 돌리지 않는다——RN의 JS 단위테스트는 별도로 설정해야 한다(예: GitLab CI의 jest job을 병렬로).
실측: 시뮬레이터 4개 병렬, smoke 전부 초록
이 체인을 실제 RN 프로젝트 하나에서 통과시켰다: UITest scheme의 TestAction을 Release configuration으로 설정, smoke 케이스 testAppLaunches는 app이 떠서 죽지 않는지만 검증. push 뒤 Xcode Cloud가 자동 발화, Test action이 4개 destination(iPhone SE / 16 / 16 Pro / 16 Pro Max)에서 병렬로 돌아 전부 초록.
실측에서 밟은 세 개의 자명하지 않은 포인트:
- Release configuration이 Cloud에서는 UITest를 돌릴 수 있다. 로컬 실기기에서 Release를 쓰면
test bundles not available in Release configuration에 부딪히지만, Xcode Cloud의 build-for-testing / test-without-building 분리 모드는 문제없다. TestAction에 Release를 직접 설정하면 된다. - 스크린샷은 기본으로 남지 않는다, 명시적으로 살려둬야 한다. XCTest의 attachment는 기본
.deleteOnSuccess다——테스트가 통과하면 지워져서 "통과했는데 이미지 없음"이 된다..keepAlways로 바꿔야 .xcresult에 들어가고, Gallery 탭과 Xcode Organizer에서 볼 수 있다. - 스크린샷은 Artifacts 탭에 없다. Artifacts는 패키징 산출물만 나열한다(Logs / Test Products / .xcresult.zip). attachment는 .xcresult 패키지 안의 리소스다. 보려면 Tests → Gallery로 가거나, .xcresult를 다운로드해
xcrun xcresulttool export attachments로 내보낸다.
심화: 환경변수로 BDD 게이트
smoke는 베이스라인이라 매번 돈다. 하지만 실기기 BDD(UI 상호작용, 비즈니스 흐름)는 시간이 오래 걸리고 할당량을 잡아먹어서 매 build마다 돌면 안 된다. 환경변수로 게이트하는 것을 관례로 삼는다:
import XCTest
final class YourAppBDDTests: XCTestCase {
func testLoginFlow() throws {
// BDD 스위트는 RUN_BDD로 게이트; smoke는 게이트 안 함 (베이스라인은 항상 실행)
try XCTSkipIf(ProcessInfo.processInfo.environment["RUN_BDD"] != "true",
"BDD 건너뜀 (기본값은 할당량 절약, 실행하려면 RUN_BDD=true 추가)")
// ... 실제 BDD 단계
}
}클라우드 workflow의 Environment는 기본적으로 RUN_BDD를 설정하지 않는다(매번 smoke만); 상호작용/비즈니스를 검증할 때 임시로 RUN_BDD=true를 추가해 한 번 트리거한다. 할당량을 중요한 곳에 쓴다.
디버그 checklist
Cannot code sign ... does not have an Info.plist→GENERATE_INFOPLST_FILE = YES설정Multiple commands produce ...-Runner.app/PlugIns/...xctest→ test 타깃에 섞인 codegenPBXShellScriptBuildPhase삭제Multiple commands produce .../PlugIns/.xctest(이름 비어 있음) →PRODUCT_NAME설정- Test action이 test target을 찾을 수 없다고 에러 → scheme TestAction이 고아 참조, 새 UITests scheme을 만들어 선택
- 클라우드 build가 test를 안 돌림 → Test action 추가 뒤 build를 다시 트리거하지 않았음(예전 build는 보충 실행 안 됨)
- 시뮬레이터 destination을 못 찾음 → 클라우드 이미지에 실제 있는 모델(예:
iPhone Air)을 쓴다. 갓 출시돼서 이미지가 아직 업데이트되지 않은 모델은 쓰지 마라 xcodebuild test컴파일이 오래 걸림 → RN 전량 컴파일 첫 회는 약 10분, 정상이니 백그라운드로 돌려두고 기다린다