코드 커버리지가 90%인데도 새벽 2시에 호출을 받았다.
단위 테스트는 통과했다. CI는 초록불이었다. 그런데 버그는 어김없이 프로덕션에 올라갔다. 커버리지가 거짓말을 한 건 아니지만, 진실을 말해준 것도 아니다. 커버리지는 어떤 라인이 실행됐는지 쟀을 뿐, 어떤 동작이 실제로 검증됐는지는 측정하지 않는다.
대부분의 팀은 피를 흘리며 이 사실을 깨닫는다. 수백 개의 단위 테스트를 작성하고, 커버리지 배지가 초록색으로 바뀌는 걸 본 뒤 요새가 안전하다고 착각한다. 벽은 있다. 하지만 지붕이 없다.
단위 테스트는 당신이 상상할 수 있는 문제만 테스트한다
단위 테스트는 코드에 대한 당신의 가정을 검증한다. 문제는 버그가 당신의 가정을 신경 쓰지 않는다는 점이다.
간단한 가격 계산 함수를 보자:
def calculate_total(items, tax_rate):
subtotal = sum(item["price"] * item["quantity"] for item in items)
tax = subtotal * tax_rate
return round(subtotal + tax, 2)
전형적인 단위 테스트 스위트는 견고해 보인다:
def test_calculate_total_with_tax():
items = [{"price": 10.00, "quantity": 2}]
assert calculate_total(items, 0.08) == 21.60
def test_calculate_total_empty_cart():
assert calculate_total([], 0.08) == 0.00
둘 다 통과한다. 커버리지는 100%다. 함수는 배포된다.
그런데 일본 고객이 ¥100짜리 상품 세 개를 장바구니에 담고 결제를 시도한다. 세율은 0.10이다. 예상 합계는 ¥330이다. 함수는 ¥330.00을 반환한다. 괜찮다.
스위스 고객이 CHF 12.35짜리 상품 하나를 7.7% VAT와 함께 구매한다. 예상: CHF 13.30. 실제: CHF 13.30. 여전히 괜찮다.
그리고 오리건 주에서 $0.01짜리 상품 두 개를 구매하는 고객이 있다. 해당 주의 세율은 0.0이다. 예상: $0.02. 실제: $0.02. 통과.
버그는 알 수 없는 우편번호 때문에 세금 서비스가 null을 반환하여 세율이 None인 관할 구역의 고객이 결제를 시도할 때 발생한다. 함수는 subtotal * None을 계산하고 TypeError를 던진다. 당신의 단위 테스트는 세율이 항상 float일 거라고 가정했기 때문에 None을 전달한 적이 없다.
이것이 근본적인 한계다. 단위 테스트는 테스트할 생각이 들었던 경로만 실행한다. 버그는 당신이 테스트하지 않은 경로에 산다.
단위 테스트가 닿지 못하는 네 곳
통합 경계
단위 테스트는 외부 의존성을 mock으로 대체한다. mock은 예의 바르다. 정확히 당신이 시키는 대로만 한다. 실제 API는 예의가 없다.
mock 데이터베이스는 수 밀리초 만에 행을 반환한다. 프로덕션은 수 초가 걸리거나 타임아웃되거나, read replica 지연 때문에 중복 행을 반환한다.
mock HTTP 클라이언트는 깔끔한 JSON을 반환한다. 실제 서비스는 화요일에 200 상태 코드와 빈 본문을 반환한다.
mock은 당신이 다른 시스템에 대해 가정한 것에 대해 코드를 테스트한다. 프로덕션은 당신의 코드를 현실에 대해 테스트한다. 이 둘은 다른 테스트 스위트이며 통과율도 다르다.
상태 및 시간 관련 버그
단위 테스트는 격리된 환경에서 실행된다. 각 테스트는 새로운 상태를 받는다. 프로덕션은 장기 실행 프로세스로, 상태가 누적되고 누출되며 자기 자신과 상호작용한다.
부하가 걸리면 항목을 제거하는 메모리 캐시. 10,000개의 요청 후에 고갈되는 커넥션 풀. 일광절약제 시간대 경계를 넘어 테스트가 실행될 때 실패하는 타임스탬프 비교. 이런 버그들은 시간, 양, 또는 순서가 필요하다. 단위 테스트에는 이것들이 없다.
동시성 및 레이스 컨디션
두 명의 사용자가 동시에 같은 레코드를 업데이트한다. 한 요청은 잔액을 읽고, 다른 요청은 차감하고, 첫 번째 요청은 오래된 값을 다시 쓴다. 돈이 사라진다. 당신의 단위 테스트는 단일 스레드에서 순차적으로 실행된다. 이를 잡아낼 수 없다.
개별 lock primitive에 대한 단위 테스트는 작성할 수 있다. 하지만 전체 시스템이 레이스 없음을 증명하는 단위 테스트는 작성할 수 없다. 상태 공간이 너무 크고 타이밍이 비결정적이다.
환경 그 자체
테스트는 Ubuntu 22.04, Python 3.11, 4GB RAM, 방화벽 규칙 없이 실행된다. 프로덕션은 Alpine Linux, Python 3.11, 512MB RAM, 그리고 60초 후 유휴 TCP 연결을 끊는 보안 그룹에서 실행된다.
socket 모듈은 다르게 동작한다. mmap 한도는 더 낮다. 로케일 설정 때문에 strftime이 파서가 예상하지 못하는 방식으로 날짜를 포맷팅한다. 이것들은 코드 버그가 아니다. 컨텍스트 버그다. 단위 테스트에는 컨텍스트가 없다.
커버리지 비율이 왜 오도하는가
커버리지 도구는 라인 실행을 측정하지, 어서션의 질은 측정하지 않는다. 테스트는 함수의 모든 라인을 실행하면서도 의미 있는 것을 전혀 검증하지 않을 수 있다.
def test_poor_coverage_quality():
result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
# Executed 100% of lines. Verified almost nothing.
assert result is not None
이 테스트는 100% 라인 커버리지를 주지만 신뢰도는 제로다. 많은 팀은 이 지표가 측정하기 쉬우니까 지표를 최적화한다. 신뢰는 측정하기 어렵다. 그래서 커버리지를 대신 측정하고 둘이 상관관계가 있기를 바란다.
그렇지 않다.
대신 무엇을 테스트해야 하는가 (또는 추가로)
이것은 단위 테스트에 반대하는 논쟁이 아니다. 단위 테스트는 빠르고, 결정적이며, 알고리즘 로직을 검증하기에 훌륭하다. 단지 불완전할 뿐이다.
CI 파이프라인을 45분짜리 민폐로 만들지 않고도 틈을 메울 방법은 다음과 같다.
시스템 내부가 아닌 경계에서 테스트하기
데이터베이스를 mock하는 대신, 실제 테스트 데이터베이스에 접근하는 테스트를 작성하라. 이것은 더 느리므로 선택적으로 실행하라. 하지만 ORM 쿼리와 쿼리 플래너의 실제 동작 사이의 불일치를 잡아낸다.
HTTP 클라이언트를 mock하는 대신, 다운스트림 서비스를 컨테이너에서 띄워라. 이것은 스키마 드리프트, 타임아웃 동작, 그리고 실제 연결 실패에서만 트리거되는 재시도 로직을 잡아낸다.
외부 서비스를 위한 contract tests 추가하기
실제 의존성을 CI에서 실행할 수 없다면, contract tests를 사용하라. 이것은 당신의 소비자 기대가 제공자의 실제 API 스키마와 일치하는지 검증한다.
Pact와 같은 도구는 서비스와 의존성 간의 상호작용을 기록한다. 제공자가 필드 타입을 변경하거나 엔드포인트를 삭제하면, contract tests가 코드가 배포되기 전에 실패한다. 통합 테스트만큼 좋지는 않지만, mock이 정확할 거라고 희망하는 것보다 훨씬 낫다.
엣지 케이스를 위한 property-based testing 사용하기
Hypothesis(Python)나 fast-check(JavaScript) 같은 property-based testing 도구는 수천 개의 무작위 입력을 생성하고 invariants가 유지되는지 검증한다.
from hypothesis import given, strategies as st
@given(
st.lists(st.fixed_dictionaries({
"price": st.decimals(min_value=0, max_value=10000, places=2),
"quantity": st.integers(min_value=0, max_value=1000)
})),
st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
if tax_rate is None:
with pytest.raises(TypeError):
calculate_total(items, tax_rate)
return
result = calculate_total(items, tax_rate)
assert result >= 0
assert result == round(result, 2)
이 테스트는 당신이 None 세율 케이스를 생각해서 작성하지 않아도 해당 버그를 잡아냈을 것이다. 이것은 당신이 결코 생각하지 못할 입력을 생성한다: 빈 리스트, 거대한 리스트, 0 가격, 최대 정밀도의 소수. 먼저 상상할 필요 없이 당신의 로직의 경계를 찾아낸다.
프로덕션을 테스트 환경처럼 모니터링하기
가장 정직한 테스트 스위트는 프로덕션 트래픽이다. 배포 전에 버그를 잡을 수 없다면, 피해를 입기 전에 잡아라.
피처 플래그를 사용해 변경 사항을 먼저 1%의 사용자에게만 롤아웃하라. 에러율, 지연 시간 백분위수, 비즈니스 지표를 주시하라. 단위 테스트는 코드가 격리된 환경에서 예상대로 동작하는지 알려준다. 프로덕션 모니터링은 코드가 현실에서 예상대로 동작하는지 알려준다.
이상 징후에도 경고를 설정하라. 단순히 하드 실패가 아니라. 배포 후 500 에러가 5% 증가하는 것은 종종 레이스 컨디션이나 리소스 누출이 시작된 유일한 신호다. 단위 테스트는 이걸 절대 보여주지 않는다.
정직한 트레이드오프
단위 테스트는 저렴하고, 빠르고, 개발자 피드백 루프에 좋다. 통합 테스트는 비싸고, 느리며, 실제로 중요한 버그를 잡는 데 좋다.
둘 다 필요하다. 함정은 100% 단위 테스트 커버리지가 나머지를 건너뛸 수 있다는 착각이다. 이는 쉬운 부분을 철저히 테스트했다는 의미일 뿐이다. 밤에 당신을 깨우는 어려운 부분은 테스트가 보지 못하는 곳에 산다.
로직과 알고리즘에 대한 단위 테스트부터 시작하라. 모든 시스템 경계에서 통합 테스트를 추가하라. 생각하지 못한 입력을 찾기 위해 property-based testing을 사용하라. 모든 테스트가 놓친 것을 잡기 위해 프로덕션을 모니터링하라.
커버리지는 허영 지표다. 유일하게 중요한 지표는 밤을 편안히 보낼 수 있는지 여부뿐이다.
버그를 놓치는 테스트를 보완하고 싶다면, 에러 데이터부터 살펴보라. Sentry는 프로덕션에서 무엇이 망가지는지, 단위 테스트가 결코 가질 수 없었던 스택 트레이스와 컨텍스트와 함께 보여준다.