동일한 Swift 테스트 모음이 개별 실행에서는 모두 통과하지만 클라우드 Mac의 병렬 작업에 넣으면 간헐적으로 실패하는 경우가 있습니다. 대개 원인은 머신의 불안정성이 아니라 테스트가 디렉터리, 환경설정, 데이터베이스, 포트 또는 난수 상태를 공유하기 때문입니다. 재시도 횟수를 늘리면 이런 오염을 가릴 뿐입니다. 더 안정적인 방법은 각 테스트에 식별할 수 있고 정리 가능하며 재현 가능한 실행 경계를 부여하는 것입니다.
먼저 공유 상태가 실패 원인인지 확인하기
처음부터 직렬 실행으로 전환하지 말고 병렬성을 유지한 채 확인합니다. 동일한 테스트 대상을 단일 워커와 여러 워커로 각각 실행하고, 두 실행에 대해 별도의 결과 번들을 생성합니다.
set -o pipefail
xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled NO \
-resultBundlePath Artifacts/serial.xcresult
xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-resultBundlePath Artifacts/parallel.xcresult
단일 워커에서는 안정적이지만 병렬 실행에서 실패한다면 어설션부터 의심하기보다 다음 리소스를 먼저 점검해야 합니다.
| 공유 리소스 | 일반적인 증상 | 격리 방법 |
|---|---|---|
| 임시 디렉터리 | 파일을 덮어쓰거나 정리할 때 파일이 존재하지 않음 | 테스트마다 고유한 하위 디렉터리 생성 |
| UserDefaults | 다른 테스트가 기록한 값을 읽음 | 독립적인 suiteName 사용 |
| SQLite 파일 | 잠금 충돌 또는 데이터 개수 변동 | 테스트마다 독립적인 데이터베이스 사용 |
| 고정 포트 | Address already in use | 포트 0에 바인딩 |
| 전역 난수 상태 | 실행 순서가 바뀌면 결과가 달라짐 | 난수 시드를 명시적으로 저장 |
직렬 실행이 통과했다는 사실은 공유 상태에 동시에 접근하지 않았다는 것만 증명할 뿐, 테스트가 올바르게 격리되었다는 뜻은 아닙니다.
테스트마다 독립적인 샌드박스 할당하기
모든 테스트가 /tmp/app-tests에 쓰도록 해서는 안 됩니다. 테스트를 시작할 때 고유 식별자를 생성하고 파일, 데이터베이스, 내보낸 결과를 모두 해당 디렉터리에 저장한 뒤 종료 시 정리합니다.
import Foundation
import Testing
struct TestSandbox {
let id: String
let root: URL
init(name: String) throws {
id = "\(name)-\(UUID().uuidString)"
root = FileManager.default.temporaryDirectory
.appending(path: "ZoneMiniTests")
.appending(path: id)
try FileManager.default.createDirectory(
at: root,
withIntermediateDirectories: true
)
}
func preferences() throws -> UserDefaults {
guard let defaults = UserDefaults(suiteName: "tests.\(id)") else {
throw CocoaError(.fileWriteUnknown)
}
return defaults
}
func remove() throws {
try FileManager.default.removeItem(at: root)
UserDefaults.standard.removePersistentDomain(
forName: "tests.\(id)"
)
}
}
의존성 주입을 통해 테스트 대상 코드에 root를 전달하여 프로덕션 코드가 전역 임시 경로를 직접 읽지 않게 합니다. 정리 작업은 defer에 두어 어설션이 실패해도 실행되도록 해야 합니다. 실패 당시 상태를 보존해야 한다면 환경 변수에 따라 삭제를 건너뛰고 샌드박스 경로를 테스트 첨부 파일에 기록할 수 있습니다.
테스트 이름만 고유 키로 사용하지 않기
매개변수화 테스트는 동일한 함수 이름으로 여러 입력을 병렬 실행할 수 있습니다. 함수 이름만 사용하면 여전히 충돌하므로 테스트 이름, 매개변수 요약, UUID를 조합해야 합니다. 매개변수에 토큰이나 사용자 데이터가 포함되어 있다면 이를 디렉터리 이름에 직접 넣지 말고 먼저 역변환할 수 없는 해시를 생성합니다.
설정, 데이터베이스, 리스닝 포트 격리하기
UserDefaults의 표준 도메인은 프로세스 수준에서 공유되는 상태입니다. 테스트에는 전용 인스턴스를 주입하고 종료 시 해당 영구 도메인을 삭제해야 합니다. 데이터베이스에도 같은 원칙이 적용됩니다. 테스트마다 자체 파일을 만들고, 마이그레이션 테스트와 일반 읽기·쓰기 테스트가 동일한 복사본을 재사용하지 않게 합니다.
네트워크 테스트에서는 고정 포트 때문에 허위 실패가 발생하기 쉽습니다. 테스트 서버는 포트 0에 바인딩하여 시스템이 사용 가능한 포트를 선택하게 한 다음, 실제 할당된 포트를 클라이언트에 전달해야 합니다. 먼저 빈 포트를 검색하고 검색용 연결을 닫은 뒤 다시 바인딩하는 방식은 사용하지 마십시오. 검색과 바인딩 사이에 경쟁 조건이 발생할 수 있습니다.
전역 싱글턴에 실제로 의존하고 단기간에 리팩터링할 수 없는 테스트는 해당 범위만 직렬화할 수 있습니다.
import Testing
@Suite(.serialized)
struct LegacyDatabaseTests {
@Test
func migratesExistingStore() async throws {
// 테스트 구현
}
}
.serialized는 레거시 범위만 감싸야 합니다. 테스트 대상 전체를 직렬화하면 병렬 상태 오염은 사라지지만 실행 시간과 문제를 추적할 단서도 함께 사라집니다.
반복 실행에 기대지 말고 난수 시드 고정하기
순서 섞기, 재시도 백오프 또는 데이터 생성이 포함된 테스트는 환경 변수에서 시드를 읽어야 합니다. 작업이 시작될 때 시드를 한 번 생성해 기록하고, 모든 테스트 프로세스가 이 값을 바탕으로 자체 하위 시드를 파생하도록 합니다.
let environment = ProcessInfo.processInfo.environment
let seed = UInt64(environment["TEST_SEED"] ?? "") ?? 20260806
하위 시드는 기본 시드와 테스트 고유 키를 사용해 안정적으로 계산할 수 있습니다. 실패 보고서에는 최소한 기본 시드, 워커 수, 테스트 대상, 실행 명령을 남겨야 합니다. 그래야 무작정 열 번 다시 실행하는 대신 ‘특정 입력과 특정 병렬도’의 조합을 재현할 수 있습니다.
ZoneMini의 상시 실행 노드에서는 이후 작업이 이전 결과를 덮어쓰지 않도록 작업 번호에 해당하는 디렉터리에 결과 번들을 저장하는 것이 좋습니다.
RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
mkdir -p "Artifacts/$RUN_ID"
TEST_SEED=20260806 xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-resultBundlePath "Artifacts/$RUN_ID/tests.xcresult"
부하를 걸어 반복 검증하기
수정한 뒤 한 번만 실행해서는 안 됩니다. 먼저 동일한 시드와 병렬도를 유지한 채 연속으로 실행하고, 이후 시드를 바꿔 서로 다른 입력을 검증합니다. 각 실행의 결과 번들은 서로 다른 경로를 사용해야 합니다. 그렇지 않으면 대상이 이미 존재하므로 xcodebuild가 종료됩니다.
검수에는 다음 네 가지 명확한 기준을 적용할 수 있습니다.
- 단일 워커와 네 워커 실행에서 동일한 어설션 결과가 나옵니다.
- 각 테스트는 자체 디렉터리, 설정 도메인, 데이터베이스에만 씁니다.
- 리스닝 서비스는 하드코딩된 포트를 사용하지 않습니다.
- 실패 로그에서 시드, 샌드박스, 결과 번들을 확인할 수 있습니다.
마지막으로 정리 정책을 점검합니다. 성공한 작업에서는 임시 샌드박스를 삭제할 수 있습니다. 실패한 작업에서는 필요한 로그와 결과 번들을 보존하되 자격 증명, 저장소 토큰 또는 민감 정보가 제거되지 않은 요청 내용을 장기간 보관해서는 안 됩니다. 병렬 테스트 안정성의 핵심은 재시도를 늘리는 것이 아니라 각 테스트가 오직 자체 상태만 소유하게 하고, 실패할 때마다 재현에 충분한 조건을 남기는 데 있습니다.
자주 묻는 질문
병렬 테스트가 실패하면 먼저 병렬 실행을 꺼야 하나요?
기본 해결책으로 병렬 실행을 끄면 안 됩니다. 디렉터리, UserDefaults, 데이터베이스와 포트를 테스트별로 격리하고, 병렬화할 수 없는 기존 테스트에만 .serialized를 적용합니다.
CI에서만 나타나는 무작위 실패는 어떻게 재현하나요?
각 실행의 난수 시드, 병렬 작업자 수, 테스트 대상과 xcresult 경로를 저장합니다. 실패한 실행과 동일한 시드와 병렬도로 전체 조건을 다시 실행합니다.
병렬 테스트에서 고정 포트를 사용하면 왜 위험한가요?
여러 프로세스가 같은 포트를 바인딩하거나 다른 테스트의 서버에 연결할 수 있습니다. 서버는 포트 0을 요청하고 운영체제가 할당한 실제 포트를 클라이언트에 전달해야 합니다.
지속적인 빌드 작업에 사용할 독점 클라우드 Mac 선택
M4, 메모리, 스토리지, 노드 및 결제 주기를 확인한 뒤 기존 파이프라인에 고정 빌드 환경을 연결하세요.