LabHub
배우기 러닝패스 코스

テストツール実戦

カバレッジは目標ではなく地図だ

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

커버리지는 어디를 안 봤는지 알려 주는 지도다. 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 의 양쪽을 자동으로 보여 준다. 그래서 조건을 잘게 쪼개는 것이 메시지를 길게 쓰는 것보다 낫다.