파이썬 테스트 #6 테스트 설계: 좋은 테스트와 커버리지 읽는 법

6 분 소요

테스트가 수백 개 있는데도 배포할 때마다 버그가 새는 팀이 있습니다. 커버리지는 90%가 넘는데 정작 고객이 밟는 버그는 테스트가 못 잡습니다. 이런 팀의 테스트 코드를 열어 보면 공통 패턴이 보입니다. 검증 없이 호출만 하는 테스트, 구현 내부를 그대로 베껴 적은 테스트, 어떤 날은 통과하고 어떤 날은 실패하는 테스트입니다. 지금까지 시리즈에서 테스트를 “쓰는 도구"를 익혔으니, 이번 글에서는 테스트의 양이 아니라 질을 결정하는 설계 원칙을 정리하고, 커버리지 숫자를 어떻게 읽어야 하는지까지 다루겠습니다.

AAA 패턴: 테스트의 기본 골격 #

좋은 테스트 함수는 세 구간으로 나뉩니다. Arrange(준비), Act(실행), Assert(검증)입니다.

AAA가 눈에 보이는 테스트
def test_apply_coupon_reduces_total():
    # Arrange: 테스트 대상과 입력을 준비
    cart = Cart()
    cart.add(Item("키보드", price=50_000))
    coupon = Coupon(discount_rate=0.1)

    # Act: 검증하려는 동작을 한 번 실행
    total = cart.checkout(coupon)

    # Assert: 결과를 확인
    assert total == 45_000

주석을 꼭 달 필요는 없지만, 세 구간이 빈 줄로라도 구분되는 테스트는 실패했을 때 읽기 쉽습니다. 준비가 길어서 본론이 안 보인다면 2편의 픽스처로 빼라는 신호입니다.

“테스트 하나에 검증 하나” 원칙도 같이 따라옵니다. 이 원칙의 진짜 의미는 “assert 한 줄"이 아니라 “동작 하나"입니다. 한 동작의 결과를 확인하는 데 assert가 세 줄 필요하면 세 줄 적으면 됩니다. 피해야 할 것은 서로 다른 동작 두 개를 한 테스트에 이어 붙이는 것입니다. 예를 들어 장바구니의 추가와 제거를 한 테스트에서 차례로 검증하면, 앞 검증이 실패할 때 뒤 동작은 실행조차 안 되어 실패 보고가 절반만 나옵니다. 둘로 쪼개면 각각 독립적으로 실패하고, 테스트 이름만 봐도 어느 동작이 깨졌는지 알 수 있습니다.

구현이 아니라 동작을 테스트 #

리팩터링할 때마다 테스트가 와르르 깨진다면, 테스트가 구현에 묶여 있다는 신호입니다.

🚫 구현에 묶인 테스트
def test_get_user_uses_cache(mocker):
    service = UserService()
    spy = mocker.spy(service, "_load_from_db")
    service.get_user(1)
    service.get_user(1)
    spy.assert_called_once_with(1)   # 내부 메서드 호출 횟수를 검증

_load_from_db라는 내부 메서드 이름이 바뀌거나 캐시 전략이 달라지면, 바깥에서 보는 동작이 그대로여도 이 테스트는 깨집니다. 관찰 가능한 결과를 검증하면 리팩터링에도 살아남습니다.

✅ 동작을 검증
def test_get_user_returns_same_user_for_same_id():
    service = UserService()
    first = service.get_user(1)
    second = service.get_user(1)
    assert first == second

판단 기준은 하나입니다. 구현을 바꿔도 사용자가 보는 동작이 같다면 테스트도 통과해야 합니다.

테스트 더블 용어 정리 #

4편에서 mock과 monkeypatch를 도구로 익혔는데, 용어를 한 번 정리해 두면 코드 리뷰 대화가 빨라집니다. 진짜 객체를 대신하는 것들을 통틀어 테스트 더블(test double)이라고 부릅니다.

용어역할
dummy시그니처를 채울 뿐 실제로 쓰이지 않음인자 자리에 넘기는 None
stub정해진 값을 돌려줌항상 200을 돌려주는 가짜 응답
fake단순화된 실제 구현인메모리 SQLite, dict 기반 저장소
mock호출 여부와 인자까지 검증assert_called_once_with(...)

실무 기준은 이렇습니다. 결과(상태)를 직접 확인할 수 있으면 stub이나 fake 쪽이 테스트가 덜 깨집니다. mock의 호출 검증은 “메일을 보냈는가"처럼 결과를 달리 관찰할 방법이 없을 때 아껴 씁니다. 호출 검증을 남발하면 위에서 본 “구현에 묶인 테스트"가 되기 때문입니다.

플레이키 테스트: 가끔만 실패하는 테스트 #

같은 코드인데 어떤 실행에서는 통과하고 어떤 실행에서는 실패하는 테스트를 플레이키(flaky) 테스트라고 부릅니다. 원인은 대부분 셋 중 하나입니다.

원인전형적인 증상대응
시간 의존자정 직전, 월말에만 실패monkeypatch나 freezegun으로 시간 고정
순서 의존단독 실행은 통과, 전체 실행은 실패전역 상태 제거, 픽스처로 격리
외부 의존네트워크 상태에 따라 실패5편의 responses와 fake로 차단

순서 의존을 미리 찾아내려면 pytest-randomly 플러그인이 유용합니다. 실행할 때마다 테스트 순서를 무작위로 섞어 주므로 숨어 있던 순서 의존이 일찍 드러납니다. 그리고 가장 나쁜 대응은 “재실행하면 통과하니까 그냥 두기"입니다. 빨간불을 무시하는 습관이 들면 진짜 버그가 보내는 신호도 같이 무시하게 됩니다. 플레이키 테스트는 발견 즉시 원인을 고치거나, 못 고치면 일단 격리(skip + 이슈 등록)하는 편이 낫습니다.

pytest-cov로 커버리지 측정 #

테스트가 코드의 어디를 실행했는지 재는 도구가 커버리지입니다. pytest에서는 pip install pytest-cov로 설치하는 pytest-cov 플러그인을 씁니다.

pytest --cov=myapp --cov-report=term-missing
출력 예
Name              Stmts   Miss  Cover   Missing
-----------------------------------------------
myapp/cart.py        40      4    90%   52-55
myapp/coupon.py      18      0   100%
-----------------------------------------------
TOTAL                58      4    93%

여기서 핵심은 93%라는 숫자가 아니라 Missing 열입니다. 52〜55번 줄이 어떤 코드인지 열어 보면 “에러 처리 경로를 한 번도 테스트하지 않았다” 같은 사실이 드러납니다. --cov-branch 옵션을 추가하면 라인 단위가 아니라 분기 단위로 측정해 주므로, 켜는 것을 권장합니다.

커버리지 숫자의 함정 #

라인 커버리지 100%는 검증 100%가 아닙니다.

커버리지 100%, 검증 0%
def test_discount():
    apply_discount(10_000, rate=0.1)   # assert가 없다

이 테스트는 함수의 모든 줄을 실행하므로 커버리지에는 잡히지만, 결과가 틀려도 통과합니다. 커버리지는 “실행했는가"만 재고 “확인했는가"는 재지 않기 때문입니다.

분기와 경계도 빠져나갑니다.

라인은 전부 실행됐지만
def fee(age: int) -> int:
    discount = 0
    if age >= 65:
        discount = 2_000
    return 5_000 - discount

fee(70) 하나만 테스트해도 모든 줄이 실행되어 라인 커버리지는 100%가 됩니다. 하지만 65세 미만 경로는 한 번도 검증되지 않았습니다. --cov-branch를 켜면 이 누락은 잡아 주지만, 조건을 age > 65로 잘못 적은 off-by-one 버그는 경계값(64세, 65세)을 직접 테스트해야만 드러납니다.

그래서 커버리지 숫자를 팀 목표로 삼으면 부작용이 생깁니다. 숫자가 KPI가 되는 순간 assert 없는 테스트, getter만 두드리는 테스트가 늘어나고, 숫자는 오르는데 품질은 그대로인 상태가 됩니다. 커버리지는 “테스트가 안 닿은 곳을 찾는 지도"로 쓰고, 강제 기준은 “새로 추가한 코드 80%” 같은 게이트 정도로만 두는 편이 건강합니다.

어디까지 쓸까: 비용 대비 가치가 높은 곳부터 #

모든 코드에 같은 밀도로 테스트를 적을 필요는 없습니다. 투자 우선순위는 이렇습니다.

  1. 버그가 났던 곳: 한 번 깨진 곳은 또 깨집니다. 버그를 고칠 때 재현 테스트를 먼저 적는 습관이 수익률이 가장 높습니다
  2. 경계값: 0, 빈 리스트, 최댓값, 경계 직전과 직후의 값
  3. 핵심 도메인 로직: 돈 계산, 권한 판정처럼 틀리면 비용이 큰 곳

반대로 단순 위임 코드, 프레임워크가 이미 보장하는 동작, 곧 버려질 프로토타입은 후순위입니다. 테스트도 유지보수 대상이므로, 가치가 낮은 테스트를 쌓는 것은 자산이 아니라 부채를 쌓는 일입니다.

정리 #

  • 테스트는 Arrange, Act, Assert 세 구간으로 구성
  • 테스트 하나에 동작 하나, assert 줄 수는 유연하게
  • 구현 내부가 아니라 관찰 가능한 동작을 검증
  • 테스트 더블은 dummy, stub, fake, mock으로 구분하고 mock의 호출 검증은 아껴서 사용
  • 플레이키 원인은 시간, 순서, 외부 의존이고 재실행으로 덮지 않기
  • 커버리지는 숫자보다 Missing 열과 분기를 보는 지도이지 그 자체가 목표는 아님
  • 투자 우선순위는 버그 났던 곳, 경계값, 핵심 도메인

다음 글(#7 CI 연동)에서는 GitHub Actions에서 테스트를 자동으로 실행하고 커버리지를 리포트하는 파이프라인을 만들면서 시리즈를 마무리하겠습니다.

X