자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트

오늘 아침 CI 알림 확인했다. 빌드 실패. 다시 돌렸다. 성공. 세 번째. 실패.

Flaky 테스트다. 또.

테스트 코드에 버그가 있으면

프로덕션 코드 버그는 찾는다. QA니까. 테스트 코드 버그는? 누가 찾나.

지난달 일이다. 결제 로직 수정했다. 자동화 테스트 전부 통과. 배포했다. 다음 날 고객 문의 폭주. 결제 안 된다.

테스트 코드를 봤다. assert response.status_code == 200 이게 전부였다.

실제 결제는 안 됐다. 응답만 200이었다. 테스트는 통과. 버그는 프로덕션.

이날 배웠다. 테스트 코드도 결국 코드다. 코드면 버그가 있다.

가짜 안심이 제일 위험하다

자동화 테스트가 있으면 안심한다. 당연하다. 그린 체크 보면 ‘됐다’고 생각한다.

근데 그 체크가 거짓말이면.

3개월 전 상황. 로그인 테스트 500개. 전부 통과. 근데 실제로는 아무도 로그인 못 했다.

이유? 테스트 환경에는 DB에 테스트 계정이 있었다. 프로덕션에는 없었다. 테스트는 항상 성공. 현실은 실패.

def test_login():
    response = login("test@test.com", "password")
    assert response.status_code == 200
    # 실제 로그인 됐는지는 안 봄

통과한다. 매번. 근데 의미가 없다.

내가 만든 테스트 코드 체크리스트

4년 동안 삽질하면서 만들었다. PR마다 이거 본다.

1. 진짜 검증하는가

# 나쁜 예
def test_user_creation():
    response = create_user({"name": "test"})
    assert response.status_code == 201
    
# 좋은 예  
def test_user_creation():
    response = create_user({"name": "test"})
    assert response.status_code == 201
    
    user = get_user(response.data.id)
    assert user.name == "test"
    assert user.created_at is not None

상태 코드만 보면 안 된다. DB도 봐야 한다. 실제 데이터도.

작년에 회원가입 테스트가 있었다. 201 반환하면 통과. 근데 DB에는 안 들어갔다.

트랜잭션이 롤백됐는데 응답은 보냈다. 테스트는 몰랐다. 201만 봤으니까.

2. 독립적인가

테스트 순서 바꿔봤나. A 테스트가 B 테스트에 의존하면 안 된다.

# 나쁜 예
def test_1_create_product():
    global product_id
    product_id = create_product().id
    
def test_2_update_product():
    update_product(product_id)  # test_1에 의존

이거 진짜 많다. 병렬 실행하면 깨진다. 순서 바뀌면 깨진다.

2년 전에 이것 때문에 3일 날렸다. test_1이 실패하면 test_2도 실패. test_2가 진짜 문제인지 몰랐다.

각 테스트마다 setup, teardown 해야 한다. 귀찮다. 근데 해야 한다.

3. 명확한가

6개월 후에 내가 봐도 이해되나.

# 나쁜 예
def test_api():
    r = call(1, 2, True)
    assert r == 3
    
# 좋은 예
def test_discount_applies_to_premium_users():
    user = create_premium_user()
    product = create_product(price=10000)
    
    order = create_order(user, product, use_discount=True)
    
    assert order.final_price == 9000
    assert order.discount_amount == 1000

변수명만 봐도 알아야 한다. 매직 넘버 쓰지 말고. 의도가 보여야 한다.

작년에 후배가 쓴 테스트 봤다. test_case_1, test_case_2, test_case_3 뭘 테스트하는지 몰랐다. 후배도.

4. 빠른가

10분 걸리는 테스트는 안 돌린다. 안 돌리면 의미 없다.

우리 팀 E2E 테스트. 처음엔 45분 걸렸다. 아무도 로컬에서 안 돌렸다. CI에서만 돌렸다. PR 올리고 45분 기다렸다.

지금은 12분. 병렬 처리했다. 불필요한 sleep 제거했다. fixture 재사용했다.

# 나쁜 예
def test_workflow():
    time.sleep(5)  # "혹시 몰라서"
    check_status()
    time.sleep(5)  # 또
    
# 좋은 예
def test_workflow():
    wait_until(lambda: status_is_ready(), timeout=10)
    check_status()

sleep은 악이다. 대부분 필요 없다. 필요하면 조건부로 기다려야 한다.

5. 안정적인가

100번 돌려서 100번 같은 결과 나오나.

Flaky 테스트가 제일 짜증난다. 가끔 실패한다. 이유 모르겠다. 다시 돌리면 통과한다.

우리 팀 규칙. Flaky 테스트 발견하면 바로 비활성화. 고칠 때까지 안 돌린다.

믿을 수 없는 테스트는 없는 것보다 나쁘다. 실패해도 “또 Flaky겠지” 하면 끝이다. 진짜 버그도 무시하게 된다.

흔한 원인들:

  • 타이밍 이슈 (sleep으로 해결하려는 순간 졌다)
  • 공유 리소스 (DB, 파일, 네트워크)
  • 랜덤 데이터
  • 시간 의존성 (datetime.now() 쓰면…)
  • 외부 API

실제 PR 리뷰에서 본 것들

Case 1: 아무것도 안 하는 테스트

def test_send_email():
    send_email("test@test.com", "subject", "body")
    # assert 없음

이거 놀랍게도 많다. 함수 호출만 한다. 검증 없다. 에러 안 나면 통과.

근데 이메일 안 가도 에러 안 난다. 테스트는 통과. 의미는 없다.

Case 2: 너무 많이 테스트

def test_user_api():
    # 100줄
    # 10가지 검증
    # 3개 API 호출
    # ...

하나 실패하면 뭐가 문제인지 모른다. 테스트 하나는 하나만 검증해야 한다.

Case 3: 프로덕션 코드 복사

def calculate_discount(price):
    return price * 0.9 if price > 10000 else price
    
def test_discount():
    price = 15000
    expected = price * 0.9 if price > 10000 else price
    assert calculate_discount(price) == expected

이건 같은 로직을 두 번 쓴 거다. 둘 다 틀려도 통과한다.

기대값은 명확해야 한다. 계산하지 말고 적어야 한다.

def test_discount():
    assert calculate_discount(15000) == 13500
    assert calculate_discount(5000) == 5000

Case 4: try-except 남용

def test_error_handling():
    try:
        dangerous_operation()
        assert False, "should raise error"
    except:
        pass  # 통과

어떤 에러든 잡는다. AssertionError도 잡는다. 테스트 로직 에러도 잡는다.

def test_error_handling():
    with pytest.raises(SpecificError):
        dangerous_operation()

이렇게 해야 한다. 구체적인 에러만 기대한다.

테스트 코드 리뷰할 때 내가 보는 것

PR 올라오면 이것들 본다.

  1. 커버리지가 아니라 의미

    • 90% 커버리지는 중요하지 않다
    • 중요한 로직 제대로 검증하나가 중요하다
  2. 네이밍

    • test_1 같은 거 보이면 바로 코멘트
    • 테스트명이 문서다
  3. 픽스처 재사용

    • 같은 setup 코드 반복하면 안 된다
    • 유지보수 지옥된다
  4. 외부 의존성

    • API 호출하면 mock해야 한다
    • DB는 트랜잭션 롤백해야 한다
  5. 에러 케이스

    • Happy path만 테스트하는 거 많다
    • 실패 케이스가 더 중요하다

지난주에 리뷰했던 PR. 성공 케이스 10개. 실패 케이스 0개.

“에러 처리는요?” “그건 개발자가 잘 했을 거예요”

안 한다. 개발자는 Happy path만 생각한다. 우리가 엣지 케이스 봐야 한다.

자동화의 함정

자동화하면 다 된 것 같다. 아니다.

자동화는 반복 작업을 줄인다. 판단은 못 한다.

잘못된 자동화는 수동보다 나쁘다. 수동이면 사람이 본다. 뭔가 이상하면 안다. 자동화는 시킨 것만 한다. 틀려도 모른다.

2년 전에 UI 자동화 했다. 버튼 클릭하고 텍스트 확인했다. 텍스트는 맞았다.

근데 레이아웃이 깨졌다. 버튼이 화면 밖으로 나갔다. 텍스트만 보는 테스트는 통과했다.

배포하고 디자이너한테 혼났다.

시각적 회귀 테스트 추가했다. 스크린샷 찍어서 비교한다. 1픽셀 차이도 잡는다.

근데 이것도 문제다. 의도된 변경도 실패로 본다. 매번 베이스라인 업데이트해야 한다.

자동화는 만능이 아니다. 도구일 뿐이다.

테스트 코드도 리팩토링한다

프로덕션 코드는 리팩토링한다. 테스트 코드는?

안 한다. 대부분. “돌아가는데 뭐”

그러다가 테스트가 레거시가 된다. 수정하기 무섭다. 건드리면 깨진다.

우리 팀은 스프린트마다 “테스트 부채” 시간이 있다. 중복 제거한다. 불필요한 테스트 삭제한다. 느린 테스트 최적화한다.

지난달에 500개 테스트를 300개로 줄였다. 실행 시간은 반으로. 커버리지는 그대로.

중복이 많았다. 같은 걸 다르게 테스트했다. 통합했다.

코드 리뷰 템플릿

PR마다 이거 붙인다.

## 테스트 체크리스트

- [ ] 각 테스트가 하나의 관심사만 검증하는가?
- [ ] 테스트명이 의도를 명확히 드러내는가?
- [ ] 테스트 간 의존성이 없는가?
- [ ] 외부 의존성을 mock/stub 했는가?
- [ ] 에러 케이스를 다루는가?
- [ ] Flaky 가능성은 없는가?
- [ ] 로컬에서 빠르게 실행되는가? (< 10초)
- [ ] Magic number 대신 의미있는 상수를 사용하는가?
- [ ] Setup/teardown이 적절한가?
- [ ] 실제 버그를 잡을 수 있는 테스트인가?

마지막 항목이 제일 중요하다. “실제 버그를 잡을 수 있는가”

이거 아니면 의미 없다.

결국 사람이 본다

자동화가 아무리 좋아도. 결국 사람이 판단한다.

테스트가 실패하면 누가 보나. 테스트가 이상하면 누가 고치나. 테스트가 의미 있는지 누가 판단하나.

전부 사람이다.

자동화는 반복 작업만 줄인다. 생각은 못 한다.

좋은 테스트 코드는 좋은 코드다. 읽기 쉽고. 유지보수 쉽고. 명확하고. 신뢰할 수 있고.

프로덕션 코드만큼 신경 써야 한다. 아니, 더 써야 한다.

테스트 코드가 틀리면 프로덕션 버그를 못 잡는다. 그게 제일 무섭다.


오늘도 Flaky 테스트 하나 고쳤다. 원인은 타임아웃이었다. 3초를 5초로 늘렸다. 근본적 해결은 아니다. 언젠가 다시 깨질 것이다. 그때 또 고치겠지.