カバレッジは目標ではなく地図だ
한국어 원문으로 표시합니다.
한 줄 요약
커버리지는 어디를 안 봤는지 알려 주는 지도다. 100%를 목표로 삼는 순간 지도가 아니라 채워야 할 칸이 되고, 그때부터 아무것도 검증하지 않는 테스트가 늘어난다.
왜 이게 필요했나
이런 테스트를 본 적이 있을 것이다.
def test_create_user():
u = create_user("kim")
assert u is not None
이 테스트는 create_user 의 모든 줄을 실행하지만 아무것도 확인하지 않는다. 커버리지는 100%가 되고, 이름이 저장되지 않아도 통과한다.
반대로 진짜 위험한 곳은 대개 커버리지 밖에 있다. 오류 처리 분기, 경계값, 동시 실행. 그런 곳은 테스트를 쓰기 귀찮아서 안 쓴 곳이고, 정확히 그래서 버그가 산다.
무엇을 먼저 테스트하나
우선순위는 틀렸을 때의 비용으로 정한다.
| 우선 | 무엇 | 왜 |
|---|---|---|
| 1 | 돈·권한·데이터 삭제 | 틀리면 되돌릴 수 없다 |
| 2 | 분기가 많은 순수 함수 | 싸게 많이 검증된다 |
| 3 | 경계값과 오류 경로 | 사람이 잘 안 짜 보는 곳 |
| 4 | 통합 경로 하나 | 조각이 붙는지 |
| 5 | 화면 | 가장 비싸고 가장 자주 깨진다 |
이 순서가 테스트 피라미드가 말하는 것이다 — 아래(단위)를 넓게, 위(E2E)를 얇게.
커버리지를 읽는 법
숫자가 아니라 빠진 줄을 본다.
pytest --cov=mymod --cov-report=term-missing
Name Stmts Miss Cover Missing
mymod.py 42 6 86% 17-19, 28, 51-52
Missing 이 답이다. 17–19가 오류 처리라면 그건 채워야 할 곳이고, 51–52가 로깅이라면 안 채워도 된다. 판단은 사람이 한다.
분기 커버리지를 켜면 더 정확해진다.
pytest --cov=mymod --cov-branch
if x: 를 참으로만 지나가면 줄 커버리지는 100%지만 분기 커버리지는 50%다.
흔한 착각
"테스트가 통과하면 맞다" — 테스트는 버그의 존재를 보일 뿐 없음을 증명하지 못한다. 통과는 "내가 생각한 경우에는 맞다" 이상을 뜻하지 않는다.
"느린 테스트도 테스트다" — 느리면 안 돌린다. 안 돌리는 테스트는 없는 테스트보다 나쁘다(있다고 믿게 만든다). 단위 테스트 전체가 몇 초 안에 끝나야 한다.
무엇을 테스트할지 고르는 기준
전부 테스트할 수는 없습니다. 두 축으로 판단합니다 — 틀렸을 때의 피해 와 틀릴 확률.
| 자주 바뀐다 | 거의 안 바뀐다 | |
|---|---|---|
| 피해가 크다 | 반드시 테스트. 여기가 1순위 | 테스트. 회귀 방지 목적 |
| 피해가 작다 | 테스트하지 않아도 된다 | 테스트하지 않는다 |
결제·인증·데이터 삭제는 왼쪽 위입니다. 화면 문구나 로그 포맷은 오른쪽 아래입니다. 커버리지 숫자를 올리려고 오른쪽 아래를 채우면 유지 비용만 늘고 사고는 그대로 입니다.
테스트 피라미드와 그 반례
느리고 비싼 테스트를 위로, 빠르고 싼 것을 아래로 두는 것이 기본형입니다.
/\ E2E — 느리다(분), 잘 깨진다, 그러나 진짜를 본다
/ /----\ 통합 — DB·큐를 실제로 띄운다(testcontainers)
/ /--------\ 단위 — 밀리초, 로직만
다만 이 모양이 항상 옳지는 않습니다. 로직이 얇고 통합이 두꺼운 서비스 (CRUD API, 데이터 파이프라인)에서는 단위 테스트가 거의 값을 못 냅니다. 목(mock)으로 DB 를 흉내 내면 정작 SQL 오류를 못 잡습니다. 그런 곳은 통합 테스트를 두껍게 쌓는 편이 맞습니다.
모양보다 중요한 것은 "이 테스트가 깨지면 진짜 문제인가" 입니다. 구현을 조금만 바꿔도 깨지는 테스트는 리팩터링을 막습니다. 그런 테스트는 없는 것보다 나쁩니다.
좋은 테스트의 세 성질
결정적이다. 같은 입력에 같은 결과. 시각·난수·순서에 기대면 간헐적으로 실패하고,
간헐적 실패는 곧 무시됩니다. 시각은 주입하고(clock 인자), 난수는 시드를 고정
합니다.
독립적이다. 순서를 바꿔도, 하나만 돌려도 통과해야 합니다. 앞 테스트가 남긴 데이터에 의존하면 병렬 실행이 불가능해집니다.
의도가 이름에 있다. test_1 이 아니라 test_returns_404_when_order_belongs_to_ another_user 입니다. 실패 목록만 읽고도 무엇이 깨졌는지 알 수 있어야 합니다.
# 시각을 주입하면 결정적이 된다
def is_expired(token, now=None):
now = now or datetime.now(timezone.utc)
return token.exp < now
def test_expired_token_is_rejected():
t = Token(exp=datetime(2026, 1, 1, tzinfo=timezone.utc))
assert is_expired(t, now=datetime(2026, 1, 2, tzinfo=timezone.utc))
실무에서 진짜 중요한 것
테스트가 실패했을 때 원인이 바로 보이는가가 테스트의 값어치다.
# 나쁨 — 왜 틀렸는지 모른다
assert result == expected
# 좋음 — 무엇이 다른지 보인다
assert result.status == 200, f"응답: {result.status} {result.body[:200]}"
pytest 는 assert a == b 의 양쪽을 자동으로 보여 준다. 그래서 조건을 잘게 쪼개는 것이 메시지를 길게 쓰는 것보다 낫다.