툴과 개발 환경8 분 읽기

제 테스트가 저를 속인 세 가지 방법

테스트 스텁이 진짜보다 관대했고, 테스트 입력이 실제 호출부보다 깨끗했고, 자기검증 조건은 아무것도 검증하지 않았습니다. 셋 다 초록불이었습니다.

#testing#verification#gotchas#reality-check#first-principles
스텁은 this를 안 봐서 깨진 구현을 통과시켰고, 테스트는 ko-KR만 넣어 실제로 들어오는 ko_KR을 못 잡았고, 자기검증은 원래 본문에도 있던 단어를 찾아 변경 없이도 통과했다.
테스트가 실제 호출부보다 깨끗한 값을 넣으면, 통과는 아무 의미가 없습니다.

하루에 같은 종류의 실패를 세 번 만났습니다. 매번 테스트는 초록불이었고, 매번 문제는 테스트가 준 값이 현실보다 깨끗했다는 것이었습니다.

당신의 테스트는 실제로 들어오는 값을 넣고 있습니까, 아니면 당신이 넣기 편한 값을 넣고 있습니까?

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_id

owner_id 라는 문자열은 처음부터 거기 있었습니다. 제 검증은 변경 여부와 무관하게 항상 통과하는 조건이었습니다. 검증 자체가 거짓말을 하는 쪽은 검증을 붙였더니, 그 검증이 거짓말을 했다에서 한 번 다뤘습니다. 숫자로 확인해보니 이랬습니다.

느슨한 조건  position('owner_id' …)              = 284   ← 변경 전에도 통과
좁힌 조건    position('''owner_id'', t.owner_id' …) = 0     ← 변경 전엔 실패

여기서 잠깐

세 사례의 공통점을 한 문장으로 줄이면 무엇일까요? 저는 이렇게 정리했습니다.

테스트는 "내가 넣은 값에서 동작한다"만 증명합니다. 그 값이 실제로 들어오는 값과 다르면, 통과는 아무것도 말해주지 않습니다.

그리고 이건 커버리지로 안 잡힙니다. 세 경우 모두 해당 코드 줄은 실행됐습니다. 잘못된 입력으로 실행됐을 뿐입니다.

자가진단 3가지

  1. 당신의 목(mock)은 진짜보다 관대합니까? 진짜가 던지는 조건에서 목도 던집니까? this, null, 순서 없는 도착, 중복 호출 — 진짜가 까다로운 지점에서 목이 순해지면 그 지점은 테스트 밖입니다.
  2. 테스트 입력을 어디서 가져왔습니까? 문서에서 베꼈다면 위험합니다. 실제 호출부가 넘기는 표현식을 그대로 찍어보고 그 값을 넣으세요. ko-KRko_KR 의 차이는 로그 한 줄이면 보입니다.
  3. 당신의 불변식은 변경 전에 실패합니까? 이게 가장 값싼 검사입니다. 자기검증 조건을 변경 전 상태에 먼저 돌려보세요. 통과한다면 그 조건은 아무것도 지키지 않습니다.

솔직한 부분

세 번째는 운이 좋았습니다. 롤백 리허설을 돌리는 습관이 없었다면 그냥 통과한 줄 알았을 겁니다. 리허설의 목적은 원래 "적용이 안전한가"였는데, 이번엔 **"내 검증이 진짜 검증인가"**를 알려줬습니다.

첫 번째와 두 번째는 운이 아니라 코드 리뷰가 잡았습니다. 유닛 테스트 200개가 통과한 상태에서요.

당신의 자기검증 조건은, 마지막으로 실패하는 걸 본 게 언제입니까?

관련 글