TDD란?
TDD(Test-Driven Development)는 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 작성한 뒤, 코드를 정리하는 개발 방식이다.
일반적인 개발 흐름은 다음과 같다.
기능 구현 -> 테스트 작성 -> 수정
TDD에서는 순서가 바뀐다.
테스트 작성 -> 기능 구현 -> 리팩터링
즉, 테스트는 구현이 끝난 뒤 검증용으로만 작성하는 것이 아니라, 구현해야 할 동작을 먼저 정의하는 도구가 된다.
Red-Green-Refactor
TDD의 기본 사이클은 Red-Green-Refactor이다.
Red -> Green -> Refactor
Red
실패하는 테스트를 먼저 작성한다.
아직 구현 코드가 없거나, 요구사항을 만족하지 못하므로 테스트는 실패해야 한다.
이 단계의 목적은 다음과 같다.
- 어떤 동작을 구현할지 명확히 정의
- 테스트가 실제로 실패하는지 확인
- 잘못된 테스트가 우연히 통과하는 상황 방지
Green
테스트를 통과할 수 있는 최소한의 코드를 작성한다.
이 단계에서는 완벽한 설계보다 테스트 통과가 우선이다.
중요한 점은 필요 이상으로 많은 코드를 미리 작성하지 않는 것이다.
Refactor
테스트가 통과하는 상태를 유지하면서 코드를 정리한다.
중복을 제거하고, 이름을 명확하게 바꾸고, 책임을 적절히 나눈다.
테스트가 이미 존재하므로 리팩터링 중 동작이 깨졌는지 빠르게 확인할 수 있다.
간단한 예시
두 수를 더하는 함수를 만든다고 가정하자.
먼저 테스트를 작성한다.
def test_add_two_numbers():
assert add(2, 3) == 5
아직 add 함수가 없으므로 테스트는 실패한다.
이 상태가 Red이다.
이제 테스트를 통과하는 최소 구현을 작성한다.
def add(a, b):
return a + b
테스트가 통과하면 Green 상태가 된다.
만약 구현이 복잡해졌다면, 테스트를 유지한 채 내부 구조를 정리한다.
이 단계가 Refactor이다.
TDD가 강제하는 것
TDD를 하면 테스트를 먼저 작성해야 하므로 자연스럽게 다음 질문을 하게 된다.
- 이 함수는 어떤 입력을 받아야 하는가?
- 어떤 출력을 반환해야 하는가?
- 실패하는 경우는 무엇인가?
- 외부 의존성 없이 테스트할 수 있는가?
- 테스트하기 어렵다면 설계가 너무 강하게 결합된 것은 아닌가?
이 과정에서 코드는 테스트 가능한 형태로 바뀐다.
예를 들어 함수가 데이터베이스, 네트워크, 파일 시스템에 직접 의존하면 테스트가 어려워진다.
따라서 TDD를 적용하다 보면 의존성을 분리하고, 입력과 출력을 명확히 하는 방향으로 설계가 유도된다.
TDD의 장점
요구사항을 코드로 기록한다
테스트는 실행 가능한 명세가 된다.
문서와 달리 테스트는 코드가 바뀔 때 직접 실행할 수 있다.
따라서 현재 코드가 어떤 동작을 보장하는지 확인할 수 있다.
리팩터링이 쉬워진다
테스트가 없으면 리팩터링은 위험하다.
코드를 정리한 뒤 기능이 깨졌는지 사람이 직접 확인해야 하기 때문이다.
반면 테스트가 있으면 동작 보존 여부를 빠르게 확인할 수 있다.
과한 구현을 줄인다
테스트를 먼저 작성하면 지금 필요한 동작에 집중하게 된다.
아직 요구되지 않은 확장성, 추상화, 옵션을 미리 만들 가능성이 줄어든다.
TDD의 단점
TDD가 항상 좋은 것은 아니다.
초기 속도가 느릴 수 있다
테스트를 먼저 작성해야 하므로 처음에는 구현만 하는 것보다 느리게 느껴질 수 있다.
특히 테스트 작성에 익숙하지 않다면 더 그렇다.
테스트하기 어려운 영역이 있다
UI, 애니메이션, 외부 API, 동시성, 하드웨어 연동처럼 테스트 환경을 구성하기 어려운 영역도 있다.
이런 경우 모든 것을 TDD로 해결하려고 하기보다, 핵심 로직을 분리해서 테스트 가능한 부분부터 적용하는 편이 좋다.
나쁜 테스트는 오히려 방해가 된다
구현 세부사항에 너무 강하게 묶인 테스트는 리팩터링을 어렵게 만든다.
좋은 테스트는 내부 구현보다 외부에서 관찰 가능한 동작을 검증해야 한다.
좋은 테스트의 기준
좋은 테스트는 다음 성질을 가진다.
- 빠르게 실행된다.
- 실패 원인이 명확하다.
- 외부 환경에 덜 의존한다.
- 구현 세부사항보다 동작을 검증한다.
- 하나의 테스트가 너무 많은 것을 검증하지 않는다.
예를 들어 다음 테스트는 좋지 않다.
def test_user_service():
user = create_user("kim")
assert user.name == "kim"
assert user.id is not None
assert send_email(user) == True
assert save_to_database(user) == True
하나의 테스트에서 객체 생성, 이메일 전송, DB 저장을 모두 검증하고 있다.
실패했을 때 어떤 책임이 깨졌는지 알기 어렵다.
더 나은 방식은 책임을 나누는 것이다.
def test_create_user_with_name():
user = create_user("kim")
assert user.name == "kim"
테스트가 작아질수록 실패 원인이 명확해진다.
TDD와 설계
TDD의 핵심은 테스트 개수가 많아지는 것이 아니다.
중요한 것은 테스트를 먼저 작성함으로써 코드의 사용 방식을 먼저 생각하는 것이다.
구현 전에 테스트를 작성하면 자연스럽게 API 관점에서 코드를 바라보게 된다.
result = calculate_discount(price=10000, rate=0.1)
assert result == 9000
이 테스트를 먼저 작성하면 함수의 이름, 입력, 출력이 먼저 결정된다.
구현은 그 다음 문제다.
즉, TDD는 단순한 테스트 기법이 아니라 설계 피드백 루프에 가깝다.
언제 적용하면 좋은가?
TDD는 다음 상황에서 특히 유용하다.
- 계산 로직이 명확한 경우
- 비즈니스 규칙이 복잡한 경우
- 리팩터링 가능성이 높은 코드
- 버그가 반복해서 발생하는 영역
- 요구사항을 작은 단위로 나눌 수 있는 경우
반대로 다음 상황에서는 바로 적용하기 어려울 수 있다.
- 요구사항이 아직 매우 불명확한 경우
- UI 시각 요소를 빠르게 탐색해야 하는 경우
- 외부 시스템 의존성이 너무 강한 경우
- 테스트 환경 구축 비용이 구현 비용보다 큰 경우
이럴 때는 전체를 TDD로 하려고 하기보다, 핵심 로직만 분리해서 테스트하는 방식이 현실적이다.
정리
TDD는 다음 사이클을 반복하는 개발 방식이다.
1. 실패하는 테스트 작성
2. 테스트를 통과하는 최소 구현 작성
3. 테스트를 유지한 채 리팩터링
TDD의 목적은 테스트 파일을 많이 만드는 것이 아니다.
구현 전에 기대 동작을 명확히 하고, 리팩터링 가능한 구조를 만들고, 변경에 대한 피드백을 빠르게 얻는 것이 핵심이다.
따라서 TDD는 테스트 기법이면서 동시에 설계 방법이다.