"건너뛰기가 뭘 숨길지 물어보고, 건너뛰기를 시끄럽게 만들어."
초록 테스트 74개와 첫 탭에 난 크래시
훈련 앱이 받아쓰기 코드를 공유 키트로 옮기고 그 결과를 TestFlight 빌드로 내보냈어. 주인이 마이크를 누르자 앱이 죽었어. 매번. 같은 로직을 앱 안에 직접 갖고 있던 이전 빌드는 몇 분 전만 해도 같은 폰에서 멀쩡했는데. 테스트는 시뮬레이터에서 74개 통과했고, 케이블로 연결한 아이패드에서도 또 통과했어. 크래시 원인은 동시성 트랙에서 본 바로 그 문제야. 키트는 Swift 6 모드로 컴파일되는데, 거기서 @MainActor 메서드 안에 만든 클로저가 메인 액터 격리를 물려받았어. 오디오 엔진이 실시간 스레드에서 그 클로저를 부르자 트랩이 난 거야. 어떤 테스트 묶음도 그걸 볼 수 없었어. 오디오 엔진을 켠 테스트가 하나도 없었어. 시뮬레이터에서도, 아이패드에서도. 그래서 오디오 탭은 한 번도 안 불렸어. 초록불이 증명하는 건 하드웨어와 테스트가 실제로 돌려본 부분뿐이야.
건너뛰기는 통과로 읽혀
같은 앱의 기기 테스트엔 빠져나갈 구멍이 있었어. 음성 권한이 아직 정해지지 않았으면 건너뛰었거든. 위의 크래시를 재현할 수 있는 유일한 하드웨어에서 기기 실행 세 번이 연달아 초록불로 끝났는데, 실제로 돌린 테스트는 하나도 없었어. 건너뛰기가 섞인 테스트 묶음은 xcodebuild 출력으로도, 스크립트 요약 줄로도, 종료 상태로도 통과한 묶음과 구분이 안 돼. 이 레슨을 쓰면서 재 보니, 진짜 검사 하나를 건너뛴 패키지가 "Executed 2 tests, with 1 test skipped and 0 failures"를 찍고, 묶음을 통과로 표시하고, 0으로 끝났어. 고친 건 두 부분이야. 기기 테스트는 이제 권한을 건너뛰지 않고 요청해(기기마다 한 번 묻고, 그 뒤로는 묻지 않아). 그러자 같은 아이패드가 테스트 77개를 실패 0, 건너뛰기 0으로 돌렸어. 그리고 건너뛰기가 생길 수 있는 스크립트는 전부 건너뛴 사실을 직접 찍어서 알려.
지우지 말고 검사를 나눠
정직한 건너뛰기도 있어. 그걸 정직하게 유지하려면 건너뛰기가 지키는 검사를 둘로 나눠야 해. 시뮬레이터는 Data Protection을 구현하지 않아. .completeFileProtection으로 쓰면 쓰기는 성공하지만 보호 등급은 기록되지 않아. attributesOfItem도 보호 등급을 돌려주지 않고, 에러는 어디에도 안 나. 그렇다고 검사를 지우면 보안상 가장 중요한 동작이 어디서도 테스트되지 않아. 상수만 확인하면 "파일을 보호한다"는 검사가 슬그머니 "complete라는 변수가 있다"는 검사로 바뀌고. 그래서 정책 상수는 모든 플랫폼에서 확인하고(누가 정책을 낮추면 실패하는 게 이 검사야), 기록된 등급은 기기에서만 확인해. 다른 곳에선 이유를 밝히는 건너뛰기로 처리하고.
전송을 건너뛴 왕복
초록불의 거짓말이 하나 더 있어. 옵셔널 필드가 비어 있는 워치 페이로드가 한 번도 배달되지 않았는데, 워치는 동기화됐다고 보고했어. 전송 테스트는 딕셔너리를 만들어서 바로 다시 파싱했어. 그런데 Swift는 [String: Any]에 Optional.none도 아무렇지 않게 담아둬. 진짜 전송은 속성 목록 값만 받아. 이 결함을 잡는 테스트는 PropertyListSerialization으로 실제로 직렬화해. 그리고 먼저 망가진 형태에 돌려서 실패하는 걸 확인했어. 실패하는 걸 아무도 본 적 없는 회귀 테스트는 짐작일 뿐이야.