엔지니어링 실무

클라우드 Mac에서 무작위 테스트 실패 재현하기

클라우드 Mac에서 무작위 테스트 실패 재현하기

CI 야간 작업에서는 로컬에서 재현할 수 없는 XCTest 실패가 간헐적으로 발생할 수 있습니다. 입력은 무작위 팩토리에서 생성되고, 테스트는 무작위 순서로 실행되며, 실패하면 파이프라인이 자동으로 재시도합니다. 두 번째 실행은 성공하더라도 첫 번째 실패 때 어떤 데이터가 사용됐고 그전에 어떤 테스트 케이스가 실행됐는지는 더 이상 확인할 수 없습니다. 이런 문제를 해결하는 핵심은 재시도 횟수를 늘리는 것이 아니라, 한 번의 실행을 완전히 재현할 수 있는 실험으로 만드는 것입니다.

먼저 재현 계약 정의하기

무작위 테스트에는 최소한 세 가지 유형의 변수가 영향을 줍니다. 비즈니스 데이터의 무작위 값, 테스트 케이스의 실행 순서, 그리고 시뮬레이터와 프로세스에 남은 상태입니다. SEED=42 하나만 기록해서는 충분하지 않습니다. 코드 커밋, 테스트 목록 또는 로케일이 바뀌면 같은 시드라도 다른 실행 경로로 들어갈 수 있기 때문입니다.

각 작업이 시작될 때 실행 매니페스트를 생성하고, 이후 단계에서는 이 매니페스트만 읽도록 합니다. 최소한 다음 필드는 저장하는 것이 좋습니다.

필드 예시 용도
commit Git 커밋 해시 테스트 대상 코드 고정
seed 부호 없는 정수 무작위 데이터 재현
tests 순서가 지정된 테스트 식별자 목록 실행 순서 고정
destination 시뮬레이터 UDID 실행 대상 고정
runtime 시스템 및 Xcode 버전 도구 체인 차이 식별
locale zh_CN 또는 en_US 형식 차이 방지
result bundle .xcresult 경로 로그 및 첨부 파일 저장

“같은 테스트가 이번에는 통과했다”는 것은 재현 결과가 아닙니다. 입력, 순서, 환경이 모두 같아야 결함이 여전히 존재하는지 판단할 수 있습니다.

실행 매니페스트는 테스트 산출물과 함께 보관해야 하며, 잘릴 수 있는 콘솔 로그에 출력하는 것으로 끝내서는 안 됩니다.

무작위 데이터에 안정적인 시드 제공하기

Swift의 시스템 난수 소스는 프로덕션 코드에는 적합하지만 지정한 시드로 재현할 수는 없습니다. 난수 생성기를 의존성으로 데이터 팩토리에 전달하고, 테스트에서는 시드를 지정할 수 있는 구현을 사용하며, 프로덕션 코드에서는 계속 시스템 구현을 사용합니다.

struct SplitMix64: RandomNumberGenerator {
    private var state: UInt64

    init(seed: UInt64) {
        state = seed
    }

    mutating func next() -> UInt64 {
        state &+= 0x9E3779B97F4A7C15
        var value = state
        value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9
        value = (value ^ (value >> 27)) &* 0x94D049BB133111EB
        return value ^ (value >> 31)
    }
}

func makeQuantity<R: RandomNumberGenerator>(using rng: inout R) -> Int {
    Int.random(in: 1...20, using: &rng)
}

테스트 진입점에서는 환경 변수로부터 시드를 읽고, 값이 없으면 시스템 난수 소스로 조용히 대체하지 말고 즉시 실패시킵니다.

let rawSeed = ProcessInfo.processInfo.environment["TEST_SEED"]
let seed = rawSeed.flatMap(UInt64.init)
XCTAssertNotNil(seed, "TEST_SEED must be provided")

같은 작업에 포함된 모든 무작위 팩토리는 명확한 파생 규칙을 공유해야 합니다. 예를 들어 마스터 시드와 안정적인 테스트 케이스 식별자로 하위 시드를 계산하면, 특정 테스트가 난수를 한 번 더 가져갔다는 이유로 전체 테스트 스위트의 데이터 순서가 바뀌는 일을 방지할 수 있습니다.

테스트 목록과 실행 순서 고정하기

테스트 순서가 파일 시스템의 탐색 결과에 의존해서는 안 됩니다. 먼저 한 줄에 하나의 전체 식별자를 기록한 텍스트 목록을 관리합니다. 예를 들면 AppTests/LoginTests/testExpiredSession과 같은 형식입니다. 그런 다음 시드와 식별자의 다이제스트를 이용해 안정적으로 정렬합니다.

import hashlib
import os

seed = os.environ["TEST_SEED"]
with open("ci/tests.txt", encoding="utf-8") as source:
    tests = [line.strip() for line in source if line.strip()]

def key(name):
    value = f"{seed}:{name}".encode()
    return hashlib.sha256(value).hexdigest()

for test in sorted(tests, key=key):
    print(test)

이렇게 하면 다양한 순서를 테스트하면서도 어느 노드에서나 동일한 목록을 다시 만들 수 있습니다. 실행 전에 정렬 결과를 ordered-tests.txt로 저장한 뒤, 각 항목으로 xcodebuild 인수를 구성합니다.

set -euo pipefail

mkdir -p artifacts
python3 ci/order_tests.py > artifacts/ordered-tests.txt

args=()
while IFS= read -r test_id; do
  args+=("-only-testing:${test_id}")
done < artifacts/ordered-tests.txt

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -destination "id=${SIMULATOR_UDID}" \
  -resultBundlePath "artifacts/test-${TEST_SEED}.xcresult" \
  "${args[@]}"

이와 별도로 기록할 수 없는 무작위 정렬을 동시에 활성화해서는 안 됩니다. 그렇게 하면 목록의 순서와 실제 실행 순서가 다시 분리됩니다.

증거를 지우지 않고 시뮬레이터 상태 격리하기

재현할 때는 전용 시뮬레이터를 사용해 수동 디버깅과 CI가 같은 기기를 공유하지 않도록 해야 합니다. 테스트 시작 전에 지정된 기기를 종료하고 초기화할 수 있지만, 해당 UDID가 이 작업에 할당된 것인지 반드시 확인해야 하며 공유 기기를 무차별적으로 초기화해서는 안 됩니다.

xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b

지역, 언어, 시간대, 시작 인수도 테스트 진입점에서 명시적으로 설정해야 합니다. 네트워크 응답, 현재 시간, UUID가 단언문에 영향을 준다면 시뮬레이터가 우연히 같은 상태이기를 기대하지 말고, 프로토콜을 통해 고정된 구현을 주입해야 합니다.

앞서 실행된 테스트가 상태를 오염시킨 것으로 의심되더라도 처음부터 각 테스트 케이스를 새로운 시뮬레이터에서 실행하도록 분리하지 마십시오. 먼저 원래 순서대로 재현해 실패를 확인한 다음, 이분 탐색으로 선행 테스트 목록을 줄여 최소 오염 경로를 찾습니다. 이렇게 하면 “무작위 데이터로 인한 실패”와 “선행 상태 누수”를 구분할 수 있습니다.

최초 실패 시 전체 현장 보존하기

실패한 작업에서는 최소한 xcresult, 순서가 지정된 테스트 목록, 실행 매니페스트, 표준 출력, 코드 커밋을 보관해야 합니다. 또한 xcodebuild -version, sw_vers, 시뮬레이터 런타임도 기록해야 합니다. 민감한 환경 변수는 변수 이름과 존재 여부만 기록하고 실제 값은 저장하지 않습니다.

자동 재시도는 분류 수단으로 사용할 수 있지만, 반드시 최초 실패 현장을 보관한 후에 실행해야 합니다. 결과는 다음 세 가지로 구분하는 것이 좋습니다.

동일한 계약에서 안정적으로 실패

동일한 커밋, 시드, 순서, 시뮬레이터 상태에서 계속 실패한다면 결정론적인 비즈니스 로직과 단언문을 우선 점검합니다.

순서를 변경하면 실패

데이터는 고정한 채 선행 테스트만 조정했을 때 결과가 달라진다면 싱글턴, 알림 리스너, 데이터베이스, 캐시, 복원되지 않은 전역 설정을 집중적으로 점검합니다.

동일한 계약에서도 결과가 계속 변동

비동기 작업이 실제로 종료됐는지, 시스템 시간을 읽고 있지는 않은지, 네트워크 응답이 고정되지 않았는지, 테스트가 주입되지 않은 난수 소스를 호출하고 있지는 않은지 계속 확인합니다.

최종 목표는 재시도로 실패 표시를 없애는 것이 아니라, 모든 실패에 실행 가능한 재현 명령을 함께 남기는 것입니다. 이를 달성하면 간헐적인 문제는 더 이상 “확률적 사건”이 아니라 축소하고 비교하며 수정할 수 있는 입력 집합이 됩니다.

자주 묻는 질문

난수 시드만 기록하면 XCTest 실패를 재현할 수 있나요?

대부분 충분하지 않습니다. 코드 커밋, 테스트 목록과 순서, 시뮬레이터 런타임, 로케일, 외부 입력도 고정해야 같은 실행 경로를 재현할 수 있습니다.

테스트가 실패하면 즉시 자동 재시도해야 하나요?

먼저 최초 실패의 xcresult, 시드와 실행 순서를 보관해야 합니다. 이후 같은 재현 계약으로 별도 실행하며, 재시도 성공을 문제 해결로 간주하면 안 됩니다.

Swift 난수 생성기를 주입하면 무엇이 달라지나요?

테스트에서는 고정 시드 생성기로 같은 데이터 열을 반복 생성하고, 운영 코드에서는 시스템 난수 생성기를 그대로 사용할 수 있습니다.

ArmCLI 클라우드 Mac

다음 빌드를 위한 독점 물리 Mac 준비

작업에 맞는 모델, 리전과 대여 기간을 선택하세요. 노드는 연중 정상 운영되며, 실제 사용 가능 여부는 콘솔의 실시간 응답을 기준으로 합니다.