[CS300 #193] 테스트 주도 개발 — 실패하는 테스트부터 쓰는 이유
컴퓨터공학 300 주제 시리즈의 193번째 글이다. 전체 지도는 여기.
한 줄 요약
테스트 주도 개발(TDD, Test-Driven Development)은 실패하는 테스트를 먼저 쓰고(Red), 그 테스트를 통과할 만큼만 코드를 쓰고(Green), 구조를 다듬는(Refactor) 짧은 주기를 반복하는 개발 방식이다. 테스트 기법이라기보다 설계 기법에 가깝다.
왜 필요한가
구현을 다 끝낸 뒤에 테스트를 쓰면 세 가지 일이 흔히 생긴다.
- 테스트를 안 쓴다. 기능은 이미 “돌아가니까” 테스트는 나중으로 밀리고, 나중은 오지 않는다.
- 구현에 맞춘 테스트를 쓴다. 코드가 하는 일을 그대로 확인하는 테스트는 코드의 버그까지 정답으로 박제한다.
- 테스트하기 어려운 구조가 이미 굳었다. 함수 안에서 DB 를 직접 열고 현재 시각을 직접 읽는 코드는 테스트를 붙이려면 구조부터 고쳐야 한다.
TDD 는 순서를 뒤집어 이 문제를 피한다. 테스트를 먼저 쓰려면 “이 기능을 바깥에서 어떻게 쓸 것인가” 를 먼저 정해야 한다. 그 결정이 곧 인터페이스 설계다. 그리고 테스트를 먼저 쓴 코드는 태어날 때부터 테스트 가능한 구조를 갖는다.
핵심 개념
Red–Green–Refactor
Martin Fowler 는 TDD 를 세 단계로 요약한다. 다음에 추가할 기능 조각에 대한 테스트를 쓰고, 테스트가 통과할 때까지 기능 코드를 쓰고, 새 코드와 기존 코드를 잘 구조화되도록 리팩터링한다.
┌──────────────┐
│ RED │ 다음 기능 조각의 테스트를 쓴다. 실패하는 것을 확인한다
└──────┬───────┘
▼
┌──────────────┐
│ GREEN │ 테스트를 통과시킬 최소한의 코드를 쓴다
└──────┬───────┘
▼
┌──────────────┐
│ REFACTOR │ 중복을 없애고 이름을 다듬는다. 테스트는 계속 초록
└──────┬───────┘
└────────> 다시 RED
각 단계에 이유가 있다.
- Red 를 꼭 본다. 테스트가 처음부터 통과하면 그 테스트는 아무것도 검증하지 않고 있을 수 있다(잘못된 함수를 부르거나, 단언이 빠졌거나). 실패를 확인해야 테스트가 “실패할 수 있는” 테스트라는 것을 안다.
- Green 은 최소한으로. 지금 테스트가 요구하지 않는 기능은 쓰지 않는다. 필요할지도 모르는 일반화는 다음 테스트가 요구할 때 한다.
- Refactor 를 건너뛰지 않는다. Green 단계에서 일부러 지저분하게 짠 코드를 정리하는 단계다. 이걸 빼면 TDD 는 “테스트 있는 스파게티” 를 만든다.
삼각측량(Triangulation)
Kent Beck 이 Test-Driven Development: By Example 에서 소개한 기법이다. 테스트 하나는 가짜 구현(상수 반환, 특수 사례)으로도 통과시킬 수 있다. 서로 다른 예시를 두세 개 더하면 가짜로는 버틸 수 없게 되고, 그때 일반 규칙이 드러난다. 아래 예제의 4단계(특수 사례)에서 6단계(일반 규칙)로 가는 과정이 삼각측량이다.
TDD 의 단위는 작다
한 주기는 몇 분 단위다. 한 번에 테스트 하나, 실패 하나만 다룬다. 테스트 열 개를 먼저 다 써 놓고 구현하는 것은 TDD 가 아니다. 그러면 Red 가 열 개 동시에 켜져 어디서부터 고칠지 모르게 된다. Beck 은 다음에 쓸 테스트 후보를 테스트 목록으로 따로 적어 두고, 거기서 하나씩 꺼내 쓰라고 권한다.
오해와 한계
| 오해 | 실제 |
|---|---|
| TDD = 테스트를 많이 쓰는 것 | 테스트는 부산물이고, 핵심은 짧은 피드백 주기와 설계 |
| 모든 코드에 TDD 를 해야 한다 | 탐색용 프로토타입, UI 배치 조정 등은 맞지 않을 수 있다 |
| TDD 하면 설계가 저절로 좋아진다 | Refactor 단계에서 설계 판단을 하는 것은 여전히 사람이다 |
| 테스트 먼저 = 명세 완성 | 테스트는 예시다. 예시가 놓친 경우는 여전히 버그다 |
직접 해 보기
정수를 로마 숫자로 바꾸는 함수를 TDD 순서로 키워 본다. 실제로는 단계마다 파일을 고치고 테스트를 다시 돌리지만, 여기서는 각 단계의 구현을 v0~v3 로 남겨 한 번에 보여 준다. . 은 통과, F 는 실패다.
# 요구: 정수(1~3999)를 로마 숫자로 바꾼다. 테스트를 하나씩 늘려 가며 구현을 키운다.
TESTS = [(1, "I"), (3, "III"), (4, "IV"), (9, "IX"), (14, "XIV"), (40, "XL"), (1994, "MCMXCIV"), (3999, "MMMCMXCIX")]
def run(step, impl, upto):
results = []
for n, expected in TESTS[:upto]:
try:
got = impl(n)
except Exception as e:
got = type(e).__name__
results.append("." if got == expected else "F")
status = "GREEN" if "F" not in results else "RED"
print(f"[{status:5s}] {''.join(results):8s} | {step}")
# 1단계: 테스트 1, 2 를 먼저 쓰고 실패를 본다(구현 없음)
def v0(n): raise NotImplementedError
run("1단계 테스트 2개, 구현 없음", v0, 2)
# 2단계: 통과할 만큼만 최소 구현
def v1(n): return "I" * n
run("2단계 'I' 를 n 번", v1, 2)
# 3단계: 4 -> IV 테스트 추가. 다시 RED
run("3단계 4 -> IV 추가", v1, 3)
# 4단계: 특수 사례로 통과(아직 지저분하다)
def v2(n):
if n == 4: return "IV"
return "I" * n
run("4단계 n == 4 특수 처리", v2, 3)
# 5단계: 나머지 테스트 추가 -> RED
run("5단계 9, 14, 40, 1994, 3999 추가", v2, 8)
# 6단계: 리팩터링. 특수 사례를 '값-기호 표' 라는 일반 규칙으로 바꾼다
TABLE = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")]
def v3(n):
out = []
for value, symbol in TABLE:
count, n = divmod(n, value)
out.append(symbol * count)
return "".join(out)
run("6단계 표 기반 탐욕 변환", v3, 8)
실행 결과:
[RED ] FF | 1단계 테스트 2개, 구현 없음
[GREEN] .. | 2단계 'I' 를 n 번
[RED ] ..F | 3단계 4 -> IV 추가
[GREEN] ... | 4단계 n == 4 특수 처리
[RED ] ...FFFFF | 5단계 9, 14, 40, 1994, 3999 추가
[GREEN] ........ | 6단계 표 기반 탐욕 변환
2단계의 "I" * n 은 누가 봐도 틀린 구현이다. 그래도 그때 있던 테스트 두 개는 통과한다. TDD 에서는 이것이 정상이다. 테스트가 요구하지 않은 것은 아직 만들지 않는다. 4단계의 if n == 4 도 마찬가지로 가짜다. 5단계에서 예시가 충분히 쌓이자 특수 사례를 하나씩 더하는 방식으로는 감당할 수 없게 됐고, 그때 “큰 값부터 표에서 빼 나간다” 는 일반 규칙이 나왔다.
엄밀히 말하면 5→6단계는 한 번에 너무 많이 갔다. 실제로 할 때는 9 하나만 추가하고, 그다음 14, 그다음 40 순서로 테스트를 하나씩 늘리며 표를 키워 가는 편이 TDD 정신에 맞는다. 글의 길이 때문에 압축했다.
테스트 목록에 아직 남은 항목도 있다. 0, 음수, 4000 이상은 어떻게 할 것인가? 예외를 던질지, 빈 문자열을 돌려줄지는 요구사항 결정이다. TDD 는 이런 질문을 구현 전에 꺼내 놓게 만든다.
현업에서는
- 버그 수정에 가장 쉽게 적용된다. 버그 신고가 들어오면 먼저 그 버그를 재현하는 실패 테스트를 쓰고, 그다음 고친다. 이 테스트는 같은 버그가 다시 들어오는 것(회귀)을 영원히 막는다. 새 기능 전체를 TDD 로 하기 부담스러운 팀도 이 습관은 쉽게 들인다.
- API 설계를 먼저 해 본다. 테스트 코드는 그 API 의 첫 번째 사용자다. 테스트를 쓰는데 준비 코드가 스무 줄이라면, 실제 사용자도 그 스무 줄을 써야 한다는 뜻이다. 그 불편함이 설계 피드백이다.
- 인프라에도 같은 리듬이 있다. 홈랩 클러스터에 알림 규칙을 추가할 때, 먼저 “이 조건이면 알림이 떠야 한다” 를 확인할 방법(규칙 단위 테스트, 또는 일부러 조건을 만들어 보는 절차)을 정하고 규칙을 쓰면, 한 번도 울리지 않는 알림 규칙을 만드는 일을 피할 수 있다.
- 페어 프로그래밍과 잘 맞는다. 한 사람이 실패 테스트를 쓰고 다른 사람이 통과시키는 “핑퐁” 방식은 TDD 리듬을 지키게 해 준다.
확인 문제
- Red–Green–Refactor 각 단계에서 하는 일을 한 문장씩 쓰라.
- Red 단계에서 테스트가 실패하는 것을 반드시 확인해야 하는 이유는?
- Green 단계에서 “최소한의 코드” 만 쓰라는 이유는?
- 삼각측량이란 무엇인가? 위 예제에서 어느 단계가 이에 해당하는가?
- 버그 수정에 TDD 를 적용하는 순서를 쓰라.
풀이
- Red: 다음 기능 조각의 테스트를 쓰고 실패를 확인한다. Green: 그 테스트를 통과시키는 최소 코드를 쓴다. Refactor: 테스트를 초록으로 유지한 채 중복과 구조를 정리한다.
- 처음부터 통과하는 테스트는 실제로 아무것도 검증하지 않을 수 있다. 실패를 봐야 그 테스트가 결함을 잡을 수 있는 테스트임을 안다.
- 현재 테스트가 요구하지 않는 기능과 일반화를 미리 넣으면, 검증되지 않은 코드와 불필요한 복잡도가 생긴다.
- 서로 다른 예시 테스트를 여러 개 더해 가짜 구현으로는 통과할 수 없게 만들어 일반 규칙을 끌어내는 기법이다. 4단계(특수 사례)에서 5·6단계(예시 추가 후 표 기반 일반 규칙)로 가는 과정이다.
- 버그를 재현하는 테스트를 먼저 쓰고 실패를 확인한다. 코드를 고쳐 테스트를 통과시킨다. 필요하면 리팩터링한다. 테스트는 회귀 방지용으로 남긴다.
더 읽을거리 (References)
- Martin Fowler, TestDrivenDevelopment
- Python 문서, unittest — Unit testing framework
- Kent Beck, Test-Driven Development: By Example, Addison-Wesley, 2002 (서지 정보)
- Steve Freeman, Nat Pryce, Growing Object-Oriented Software, Guided by Tests, Addison-Wesley, 2009 (서지 정보)