앞서 TDD 책에 본격적으로 들어가기 전에 저자가 한 말 중 TDD의 프로그래밍 순서는 아래와 같다고 하였다.
- 빨강 - 실패하는 작은 테스트 작성, 처음에는 컴파일 조차 되지 않을 수 있음.
- 초록 - 빨리 테스트가 통과하게끔 만듬. 이를 위에 어떤 죄악을 저질러도 좋음
- 리팩토링 - 일단 테스트를 통과하게만 하는 와중에 생겨난 모든 중복을 제거.
1부에서는 완전히 테스트에 의해 주도되는 전형적 모델 코드를 개발할 예정이다. 위의 3 단계를 풀어낸 TDD 의 리듬은 다음과 같다.
1. 재빨리 테스트를 하나 추가
2. 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
3. 코드를 조금 바꾼다.
4. 모든 테스트를 실행하고 전부 성공하는지 확인
5. 리팩토링을 통해 중복을 제거
그리고 주의깊게 살펴야 할 점
전 포스팅에서 살펴 보았던 워드의 와이캐시에서 만들었던 다중 통화를 지원하는 Money 객체부터 시작해보자.
다음과 같은 보고서가 있다고 하자
종목 | 주 | 가격 | 합계 |
---|---|---|---|
IBM | 1000 | 25 | 25000 |
GE | 400 | 100 | 40000 |
합계 | 65000 |
다중 통화를 지원하는 보고서를 만들려면 통화 단위를 추가해야 한다.
종목 | 주 | 가격 | 합계 |
---|---|---|---|
IBM | 1000 | 25USD | 25000USD |
GE | 400 | 150CHF | 60000CHF |
합계 | 65000USD |
또한 환율도 명시해야 한다.
기준 | 변환 | 환율 |
---|---|---|
CHF | USD | 1.5 |
여기서 어떤 테스트들이 있어야 제대로 계산되도록 하는 코드가 완성되었다는 것을 확신할 수 있을까?
할일 목록
위의 할일 목록 을 테스트 코드로 작성할 구체적인 예시를 리스트로 유지하며 개발을 진행해보자.
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
- $5 * 2 = $10
위에 할일 중 곱하기를 먼저 다룬다고 생각해보자.
자 그럼 어떤 객체가 있어야 할까?
라는 생각은 일반적인 개발이고, 객체를 만들면서 시작하는게 아니라 어떤 테스트를 먼저 만들어야 하는지 고민하자. 테스트를 먼저 만들어보자.
할일 목록을 보니 첫번째 테스트는 좀 복잡해 보인다. 작은거 부터 시작하자
어떤 금액(주가)을 어떤 수(주식의 수)에 곱한 금액을 결과로 얻을 수 있어야 한다.
테스트를 작성할 때는 메서드의 완벽한 인터페이스에 대해 상상해보는 것이 좋다. 우리는 지금 구현하려는 메서드가 외부에서 어떤 식으로 보일지에 대한 이야기를 테스트코드에 적고 있는 것이다.
다음은 간단한 곱셈의 예다.
public void testMultiplication() {
Dollar five= new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
}
그리고 위의 코드에서 발생한 불편한 내용들을 체크리스트에 추가시켜주자. Public field에다가 (five.amount로 직접 클래스 속성에 접근하는 문제), 예기치 못한 부작용이 있을 수 있고, 금액을 계사나는데 정수형을 사용한다는 불편 사항들이 있을 수 있겠다.
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
- $5 * 2 = $10
- amount를 private 으로 만들기
- Dollar 부작용(side effect)?
- Money 반올림?
위에서 작성한 테스트는 아직 컴파일 조차 되지 않는다. 그럼 일단 컴파일 에러를 해결해보자.
한번에 하나씩 정복하기로 하자.
일단 Dollar 클래스를 정의하면 에러 하나는 없앨 수 있다.
class Dollar{
}
에러가 하나 없어졌으니 이제 3개 남았다. 생성자를 만들어보자. 그냥 컴파일만 되게 할 목적이니 생성자 안에서는 아무 일도 안해도 된다.
class Dollar{
Dollar(int amount){
}
}
빈 times() 함수를 구현해주자.
void times (int multiplier){
}
amount 필드를 추가하자.
int amount;
위와 같은 4개의 수정사항을 거치면 컴파일을 드디어 할 수 있게 된다! 오른쪽 테스트 코드에서 빨간색 글씨들이 다 사라진 모습을 확인할 수 있다.
하지만 테스트를 실행하면 테스트가 실패하는 모습을 볼 수 있다. 이제 빨간 막대에 다다른 것이다.
테스트는 실패하지만 원래는 컴파일도 안되는 코드였으니 진척이 있었다고 할 수 있다.
그럼 앞서 설명한 코드에서 assertEquals(10, five.amount);
의 어떤 죄악을 저지르더라도 테스트를 통과시키기 위해 다음과 같이 코드를 바꿀 수 있다.
int amount = 10;
테스트가 통과하는 걸 확인할 수 있다! 초록색을 통과한 것이다. 그럼 위에서 풀어놨던 주기를 다시 한번 확인해보자.
- 작은 테스트를 하나 추가
- 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
- 코드를 조금 바꾼다.
- 모든 테스트를 실행하고 전부 성공하는지 확인
- 리팩토링을 통해 중복을 제거
지금 1~4까지 진행했고 이제 리팩토링을 할 차례이다. 그런데 작성한 코드 중 어디가 중복일까?
이번 경우엔 중복이 테스트에 있는 데이터와 코드에 있는 데이터 사이에 존재한다. 코드를 다음과 같이 썼다면 어땠을까?
int amount = 5*2;
사실 10은 checklist에서 5 * 2로 이루어진 숫자로 test에서 5와 2를 써준 곳이 두곳 존재한다(테스트 코드와 구현 코드). 우린 이 중복을 없앨 것이다.
5와 2를 한번에 제거할 방법은 없다(?). 하지만 객체의 초기화 단계에 있는 설정 코드를 times() 메서드 안으로 옮겨 보면 어떨까?
int amount;
void times(int multiplier){
amount = 5 * 2;
}
이런 너무 작은 스텝들이 지루하게 여겨질 수도 있지만 이런 식으로 차근차근 작은 단계를 밟아보는 연습을 하면 나중에 정말 어려운 문제가 발생했을 때 작게 나눌 수 있는 능력이 생길 것이라 생각한다.
그러면 위의 코드에 5
라는 값은 어디서 얻을 수 있을까?
이는 생성자에서 넘어오는 값이니 다음과 같이 amount 변수에 저장하고 이를 times() 에서 사용하게 할 수 있다.
public Dollar(int amount){
this.amount = amount;
}
void times(int multiplier){
amount *= 2;
}
(amount = amount * 2
를 저런식으로 표현한 것도 중복을 제거하는 일환이다.)
인자 multiplier
의 값이 2 이므로, 상수를 이 인자로 대체할 수 있다.
void times(int multiplier){
amount *= multiplier;
}
이제 첫 번째 테스트에 완료 표시를 할 수 있게 되었다.
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
$5 * 2 = $10- amount를 private 으로 만들기
- Dollar 부작용(side effect)?
- Money 반올림?
지금까지 우리는 아래와 같은 작업들을 했다.
우리는 이전에 작동하는 깔끔한 코드 를 만드는 것을 목표로 이 프로젝트를 진행한다고 했었다. 하지만 이는 최고의 프로그래머들 조차 달성하기 힘든 목표라고들 한다. 그렇다면 이는 divide&conquer 하여 정복해야 하는데, 여기서 ‘작동하는’ 파트 -> '깔끔한 코드' 파트를 해결하는 방식으로 우리는 문제들을 해결해 나갈 것이다. 이런 접근 방식은 설계하고 코드를 작성하는 아키텍쳐 주도 개발과 정반대 되는 부분이다.
이제 체크리스트를 살펴보자
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
$5 * 2 = $10- amount를 private 으로 만들기
- Dollar 부작용(side effect)?
- Money 반올림?
이제 문제는 Dollar에 대해 연산을 수행한 후에 해당 Dollar의 값이 바뀌어 다음에 사용할 때 이미 바뀐 값에 multiply 가 된다는 문제가 발생한다. 이를 Dollar의 부작용이 가지고 있는 의미이다.
우리는 아래와 같은 케이스를 통과하고 싶다.
public void testMultiplication2(){
Dollar five= new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
five.times(3);
assertEquals(15, five.amount);
}
times()
를 처음 호출한 이후에 five
는 더이상 5가 아니다.
여기에서 times()
에서 새로운 객체를 반환하게 만들면 원래의 5를 가지고 왼종일 곱하기를 수행하더라도 원래 달러 객체 five
는 변하지 않을 것이다.
이렇게 바꾸기 위해서는 Dollar의 인터페이스를 수정해야 하고, 그러기 위해서는 테스트도 수정해야 한다. 문제될 건 없다. 어떤 구현이 올바른가에 대한 우리 추측이 완벽하지 못한 것과 마찬가지로 올바른 인터페이스에 대한 추측 역시 절대 완벽하지 못하다고 한다.
그러면 times()
가 새로운 객체를 반환하는 함수라 생각하고 테스트를 작성해보면 아래와 같다.
@Test
public void testMultiplication2(){
Dollar five= new Dollar(5);
Dollar product = five.times(2);
assertEquals(10, product.amount);
product = five.times(3);
assertEquals(15, product.amount);
}
이 역시 빨간줄이 뜨면서 컴파일조차 되지 않는 코드가 된다.
그럼 일단 컴파일이 되도록 하면 어떻게 해야 할까?
구현 코드로 가서 times()
가 Dollar 객체를 반환하도록 해야한다.
Dollar times (int multiplier){
amount *= multiplier;
return null;
}
컴파일 오류는 해결했지만 테스트는 실행되지 않을 것이다. 해당 메서드가 올바른 금액을 갖는 객체를 반환하도록 설정해주자.
Dollar times (int multiplier){
return new Dollar(amount * multiplier);
}
테스트를 패스한걸 확인할 수 있다.
checklist
- $5 + 10CHF = $10 (환율이 2:1일 경우)
$5 * 2 = $10- amount를 private 으로 만들기
Dollar 부작용(side effect)?- Money 반올림?
우리는 여태
느낌(뭔가 아닌거같은 것, 부작용에 대한 혐오감) 을 테스트(하나의 Dollar
객체에 곱하기를 두번 수행하는것) 로 변환하는 것은 TDD의 일반적 주제이다. 이런 작업을 오래 할수록 미적 판단을 테스트로 담아내는 것에 점점 익숙해지게 된다.
이걸 할 수 있을 때 우리는 일단 올바른 행위에 대해 결정을 내린 후에, 그 행위를 얻어낼 수 있는 최상의 방법에 대해 이야기할 수 있다.