하루에 같은 종류의 실패를 세 번 만났습니다. 매번 테스트는 초록불이었고, 매번 문제는 테스트가 준 값이 현실보다 깨끗했다는 것이었습니다.
당신의 테스트는 실제로 들어오는 값을 넣고 있습니까, 아니면 당신이 넣기 편한 값을 넣고 있습니까?
1. 스텁이 this 를 신경 쓰지 않았다
웹 페이지가 네이티브 앱으로 메시지를 보내는 브리지를 만들었습니다. 여러 경로를 순서대로 시도합니다.
const sink = host.webkit?.messageHandlers?.plotta?.postMessage;
sink(json);테스트는 11개 전부 통과했습니다. 스텁이 이랬거든요.
const host = { webkit: { messageHandlers: { plotta: { postMessage: (s) => seen.push(s) } } } };평범한 화살표 함수입니다. this 가 필요 없습니다. 그래서 떼어내 호출해도 잘 동작합니다.
진짜 postMessage 는 수신자에 묶여 있습니다. 떼어내면 TypeError: Illegal invocation 을 던집니다. 제 코드는 그걸 try/catch 로 감싸고 있었으니 조용히 삼켜졌고, 네이티브 쪽은 메시지를 한 건도 못 받은 채 45초 타임아웃까지 기다렸습니다.
고친 뒤 회귀 테스트는 이렇게 씁니다.
const plotta = {
postMessage(this: unknown, s: string) {
if (this !== plotta) throw new TypeError("Illegal invocation");
seen.push(s);
},
};스텁이 진짜만큼 까다로워야 깨진 구현을 잡습니다.
2. 테스트 입력이 호출부보다 예뻤다
앱의 언어를 웹 렌더러가 아는 다섯 개 중 하나로 접는 함수입니다.
let base = identifier.split(separator: "-").first…테스트:
XCTAssertEqual(webLocale("ko-KR"), "ko") // 통과
XCTAssertEqual(webLocale("zh-Hant"), "zh") // 통과전부 초록불. 그런데 실제 호출부는 이걸 넘깁니다.
ReelPayloadBuilder.make(… locale: Locale.current.identifier)Locale.current.identifier 는 BCP-47 태그가 아니라 ICU 형식입니다. ko_KR. 언더스코어입니다. "-" 로만 쪼개면 "ko_kr" 이 남고, 목록에 없으니 기본값 영어로 떨어집니다.
한국어와 일본어 기기의 영상이 전부 영어로 렌더되고 있었습니다. 테스트는 하이픈 형태만 알고 있었습니다. 로케일이 조용히 어긋나 잘못된 것을 내보낸 건 전에도 있었습니다 — 내 일본어 유튜브는 존재하지 않는 페이지를 광고하고 있었다.
재미있는 건, 같은 함수의 안드로이드 판은 substringBefore('-').substringBefore('_') 로 둘 다 처리하고 있었다는 점입니다. 한쪽은 맞고 한쪽은 틀렸는데, 양쪽 다 테스트는 통과했습니다.
3. 자기검증이 아무것도 검증하지 않았다
DB 함수를 고치면서 자기검증 블록을 넣었습니다. 함수가 owner_id 를 반환하도록 바꾸는 마이그레이션입니다.
IF position('owner_id' in src) = 0 THEN
RAISE EXCEPTION 'owner_id was not added';
END IF;안전해 보입니다. 적용 전에 롤백 리허설을 돌려봤더니 — 롤백 이후에도 이 조건이 통과했습니다.
원래 함수 본문에 이미 이렇게 적혀 있었기 때문입니다.
JOIN plotta.users u ON u.id = t.owner_idowner_id 라는 문자열은 처음부터 거기 있었습니다. 제 검증은 변경 여부와 무관하게 항상 통과하는 조건이었습니다. 검증 자체가 거짓말을 하는 쪽은 검증을 붙였더니, 그 검증이 거짓말을 했다에서 한 번 다뤘습니다. 숫자로 확인해보니 이랬습니다.
느슨한 조건 position('owner_id' …) = 284 ← 변경 전에도 통과
좁힌 조건 position('''owner_id'', t.owner_id' …) = 0 ← 변경 전엔 실패여기서 잠깐
세 사례의 공통점을 한 문장으로 줄이면 무엇일까요? 저는 이렇게 정리했습니다.
테스트는 "내가 넣은 값에서 동작한다"만 증명합니다. 그 값이 실제로 들어오는 값과 다르면, 통과는 아무것도 말해주지 않습니다.
그리고 이건 커버리지로 안 잡힙니다. 세 경우 모두 해당 코드 줄은 실행됐습니다. 잘못된 입력으로 실행됐을 뿐입니다.
자가진단 3가지
- 당신의 목(mock)은 진짜보다 관대합니까? 진짜가 던지는 조건에서 목도 던집니까?
this, null, 순서 없는 도착, 중복 호출 — 진짜가 까다로운 지점에서 목이 순해지면 그 지점은 테스트 밖입니다. - 테스트 입력을 어디서 가져왔습니까? 문서에서 베꼈다면 위험합니다. 실제 호출부가 넘기는 표현식을 그대로 찍어보고 그 값을 넣으세요.
ko-KR과ko_KR의 차이는 로그 한 줄이면 보입니다. - 당신의 불변식은 변경 전에 실패합니까? 이게 가장 값싼 검사입니다. 자기검증 조건을 변경 전 상태에 먼저 돌려보세요. 통과한다면 그 조건은 아무것도 지키지 않습니다.
솔직한 부분
세 번째는 운이 좋았습니다. 롤백 리허설을 돌리는 습관이 없었다면 그냥 통과한 줄 알았을 겁니다. 리허설의 목적은 원래 "적용이 안전한가"였는데, 이번엔 **"내 검증이 진짜 검증인가"**를 알려줬습니다.
첫 번째와 두 번째는 운이 아니라 코드 리뷰가 잡았습니다. 유닛 테스트 200개가 통과한 상태에서요.
당신의 자기검증 조건은, 마지막으로 실패하는 걸 본 게 언제입니까?