On this page
Fallback 브랜치 테스트 커버리지 갭
테스트는 통과해요. 커버리지는 100%예요. `|| randomUUID()`를 지워도 모든 게 통과해요. 빌더 기반 fixture가 falsy 브랜치를 어떻게 숨기는가.
claude[bot]이 PR #860에 인라인 코멘트를 남겼어요: mRefreshToken = user.refreshToken || randomUUID() 테스트가 truthy 브랜치만 실행하고
있다고요. 모든 기존 테스트는 사용자를 UserBuilder.aBeliever().withRefreshToken('x')로 만들었어요. 프로덕션에서 || randomUUID()를 빼버려도 모든 테스트가 그대로 통과해요. Fallback은
프로덕션에서 load-bearing이었지만(fresh signup은 refresh token이 null)
suite에는 invisible이었어요. Line-coverage 도구가 절대 못 잡고 AI 리뷰어가
mechanical하게 잡아내는 구조적 커버리지 갭이에요.
누가, 언제, 어디서
이 패턴은 빌더나 fixture에서 LHS가 공급되는 ||나 ?? (또는 단축 평가
fallback) 코드 모든 곳에서 나타나요. 테스트 fixture가 프로덕션 코드가
sometimes-null로 기대하는 값을 미리 채울 때마다 물려요. 핸들러, 서비스,
factory의 unit test에서 — 테스트 setup이 falsy 브랜치를 실행 불가능하게
만드는 모든 곳에서 갭을 찾을 수 있어요.
갭이 어떻게 생겼나
프로덕션 코드에 value = source || generateDefault() 같은 fallback이
있고 테스트 fixture가 항상 source를 채우면, generateDefault() 브랜치는
절대 실행 안 돼요. 모든 테스트가 통과해요. 커버리지 도구는 100% 라인
커버리지를 보고할 수도 있어요 — 라인이 실행됐으니까(LHS가 평가되고
short-circuit). 하지만 브랜치 커버리지는 불완전하고 — || generateDefault()를 지워도 어떤 테스트도 실패하지 않아요.
이건 다음을 하는 구조적 커버리지 사각지대예요:
- 우발적인 리팩터링을 숨김 (누군가 “정리” 패스에서 fallback 제거 — 보이지 않는 회귀)
- 프로덕션 전용 실행 경로를 숨김 (fresh signup은 null 필드를 가짐; 기존 fixture는 미리 채워둠)
- LHS가 fixture 제어 변수일 때 line-coverage gate에 invisible하고 대부분의 branch-coverage 도구에도 invisible
왜 함정인가
세 가지가 공모해서 갭을 숨겨요:
- 빌더 fluency가 테스트를 “완전한” 객체로 편향시켜요.
UserBuilder.aBeliever().withEmail(x).withRefreshToken(y).build()가 있으면.withRefreshToken(y)호출이 ergonomic한 경로예요. 미래의 테스트 작성자는 기존 테스트에서 copy-paste하면서 미리 채워진 상태를 상속해요. 빌더가 “complete”를 디폴트로 만들어요; “partial”은 의식적인 노력이 필요해요. - 프로덕션 코드는 “incomplete” 객체를 반환해요. Fresh signup에 대해
{ refreshToken: null }을 반환하는 factory는 누군가 명시적으로withX()호출을 빼지 않으면 테스트 fixture vocabulary에 존재하지 않아요. Fixture의 디폴트는 테스트하기 편한 걸 반영하지, 프로덕션이 실제로 emit하는 걸 반영하지 않아요. - 커버리지 도구가 라인 수준 실행을 평탄화해요.
const x = a || b()는 한 라인으로 표시돼요.a가 truthy면b()는 실행 안 되지만 라인은 “covered”예요. Branch-coverage 도구는 가끔 잡고, line-coverage 도구는 절대 못 잡아요.
선제적 수정
프로덕션 코드의 모든 ||나 ?? fallback에 대해 최소 두 개의 테스트를
써요:
- LHS truthy — fixture가 값을 미리 채움. Truthy 경로 assert.
- LHS falsy — fixture가 명시적으로 값을 채우지 않음. Fallback 경로가 실행됐는지 assert (생성된 default가 expected shape를 가짐).
Falsy 테스트는 또한 input precondition도 assert해야 해요
(expect(input.x).toBeNull()) — fixture 디폴트가 바뀌어도 테스트가 조용히
저하되지 않게요.
탐정형 수정
프로덕션 코드에 새 fallback을 추가할 때, 그 코드를 실행하는 모든 테스트를 grep해서 확인해요:
- 모든 테스트가 빌더로 LHS를 미리 채우는가?
- 그렇다면 — LHS를 생략한 평행 테스트 하나를 추가해요.
이 갭을 표시하는 AI review를 받으면(claude[bot], copilot, codex), 거의 항상 진짜예요. 패턴은 mechanical하고 AI 리뷰어가 안정적으로 잡아내요 — 긴 diff를 스캔하는 인간 리뷰어보다 훨씬 안정적이에요.
Fallback이 randomUUID()나 random output을 쓸 때
정확한 output을 assert할 수 없지만 다음은 할 수 있어요:
- Shape 매칭: UUID에 대해
expect.stringMatching(/^[0-9a-f-]{36}$/i) - Captured value 대조:
expect(updateMock).toHaveBeenCalledWith(id, accessToken, expect.any(String)) - 리다이렉트 URL이 UUID-shaped param을 포함하는지 assert:
expect(result).toMatch(/refreshToken=[0-9a-f-]{36}/i)
“값이 random이라” 테스트를 스킵하지 마세요 — 증명하는 건 브랜치 실행이지 specific value가 아니에요.
Regex 조합 함정
anchor가 있는 regex를 쓴 다음 URL 매치로 조합하면 ^...$ anchor가 URL
매치에 carry돼서 실패해요:
const uuidPattern = /^[0-9a-f-]{36}$/i;
// 잘못됨: anchor가 substring 매치로 carry됨
new RegExp(`mRefreshToken=${uuidPattern.source}`, 'i'); 조합할 때 anchor를 떼고 — standalone-arg 매칭에만 유지해요:
// 재사용 가능한 패턴 body, anchor 없이
const uuidBody = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
expect(arg).toMatch(new RegExp(`^${uuidBody}$`, "i")); // standalone arg에 anchored
expect(url).toMatch(new RegExp(`token=${uuidBody}`, "i")); // substring에 unanchored 실제 예시
auth-v1.service.ts:277의 프로덕션 코드:
const mRefreshToken = user.refreshToken || randomUUID(); 기존 테스트는 truthy 브랜치만 실행했어요:
it("should issue tokens for new user", () => {
const newUser = UserBuilder.aBeliever()
.withEmail("[email protected]")
.withRefreshToken("existing-token") // ← LHS 미리 채워짐
.build();
// ... 실행, 통과, randomUUID() 절대 실행 안 됨
}); 빠진 테스트가 falsy 브랜치를 실행해요:
it("should generate new refresh token when user has none", () => {
const newUser = UserBuilder.aBeliever()
.withEmail("[email protected]")
// ← .withRefreshToken() 생략 — refreshToken이 null로 남음
.build();
expect(newUser.refreshToken).toBeNull(); // precondition guard
const result = service.handle(input);
const uuidBody =
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
expect(updateTokenMock).toHaveBeenCalledWith(
newUser.id,
expect.any(String),
expect.stringMatching(new RegExp(`^${uuidBody}$`, "i"))
);
}); Precondition guard(expect(newUser.refreshToken).toBeNull())가
load-bearing이에요 — 그게 없으면 미래의 UserBuilder.aBeliever() 디폴트
변경이 refreshToken을 채워서 테스트를 truthy 케이스의 중복으로 조용히
바꿔버릴 수 있어요.
핵심 포인트
value || fallback()은 두 브랜치 표현식이에요. 테스트가 두 브랜치 모두 커버해야 해요.- 빌더 기반 테스트 fixture는 모든 테스트를 truthy 브랜치로 편향시켜요. Falsy 케이스에 대해 명시적이어야 해요.
- Line coverage는 못 잡아요. Branch coverage는 가끔 잡아요. AI review는 안정적으로 잡아요.
- Fallback이 non-deterministic(UUID, timestamp, random)일 때, 값이 아닌 output의 shape을 assert해요.
언제 사용할까
- 새
||/??fallback이 있는 PR 코드 리뷰. - 빌더 기반 fixture를 가진 핸들러/서비스의 테스트 작성.
- 커버리지가 높아 보이지만 프로덕션이 default-generation 경로 버그를 가진 레거시 코드 감사.
- AI review가 fallback 표현식의 누락된 브랜치 커버리지를 표시한 후.
언제 사용하지 말까
- Fallback이 상수(
x || 0,name || 'anonymous')이고 fallback 값이 truthy 테스트의 negative case에 trivially assert되는 테스트. - Fallback이 프로덕션에서 진짜로 도달 불가능한 코드(예: 업스트림 invariant로 보호) — 그 경우 fallback 자체가 dead code이므로 테스트하지 말고 제거하세요.
정리
오늘 AI 리뷰어가 가장 유용하게 하는 건 새로운 버그를 잡는 게 아니라 — 인간이
훑고 지나가는 mechanical한 패턴을 잡는 거예요. Fallback 브랜치 커버리지가
그런 패턴 중 하나예요: 정의하기 쉽고, mechanical하게 검사하기 쉽고, intent로
diff를 읽을 때 놓치기 쉬워요. claude[bot] (또는 copilot, codex)이 이런
종류의 갭을 표시하면, 거의 확실한 진짜 발견으로 다루고 평행 테스트를
추가해요.