본 글은 켄트 백의 <테스트 주도 개발>을 읽고 개인적으로 정리한 내용입니다. 내용에 오류가 있을 시 지적해주시면 감사하겠습니다.
저자의 글
테스트 주도 개발의 2원칙
- 오직 자동화된 테스트가 실패할 경우에만 새로운 코드를 작성한다.
- 중복을 제거한다.
이 규칙에 의거하여 행동 패턴이 만들어지고, 다음과 같은 프로그래밍 순서가 만들어진다.
빨강
- 실패하는 작은 테스트를 작성한다. 컴파일이 되지 않아도 좋다.초록
- 빨리 테스트가 통과하게끔 만든다. 이 과정에서 코드 복붙, 테스트 통과를 위해 함수가 무조건 상수를 반환하기 등의 꼼수를 써도 상관없다.리팩토링
- 일단 테스트를 통과하게만 하는 와중에 생겨난 모든 중복을 제거한다.
1장 - 다중 통화를 지원하는 Money 객체
다중통화를 지원하는 보고서를 생성하려면, 기존의 금액체계에 통화 단위를 추가하고, 환율을 명시해야 한다.
예를들어, $5 + 10CHF = $10(환율이 2:1일 경우)
따라서 $5 * 2 =$10
이 과정에서 해야할 일은 두 가지가 나온다.
- 통화가 다른 두 금액을 더해 주어진 환율에 맞게 변한 금액을 결과로 얻을 수 있어야 함
- 주가 * 주식 수의 금액을 결과로 얻을 수 있어야 함
보아하니 첫번째 테스트는 좀 복잡해보인다. 가벼운 일부터 먼저 처리하자. 두번째 테스트부터 작성한다.
public void testMultiplication() {
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
}
이 코드는 지금 '빨강' 상태이다. public 필드이기도 하고, 금액 계산에 정수형을 사용하고 있다. 일단 '초록'을 보기 위해 문제사항들을 다 적어놓고 진행한다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
- $5 * 2 = $10
- amount를 private으로 만들기
- Dollar 부작용?
- Money 반올림?
컴파일이 되게 만드려면 다음과 같은 컴파일에러를 해결해야한다.
- Dollar 클래스가 없음
- 생성자가 없음
- times(int) 메서드가 없음
- amount 필드가 없음
일단 컴파일이 되게 하기 위해서, 위의 구현들을 정말 '최소한'으로 한다.(정말 아무 로직도 안짜도 된다)
class Dollar // #1
Dollar(int amount) // #2
void times(int multiplier) // #3
int amount; // #4
컴파일은 되었지만, amount의 예상액이 10이 나와야 assertEquals를 통과하는데, amount값이 0이라 통과하지 못한다. 따라서
int amount=10;
amount를 그냥 10으로 바꿔준다.(...??)
어찌됐든 결과가 앞으로 이렇게 나와야 하니, 껍데기만 작성 해두자. 그럼 테스트를 통과했다!
그렇다면 이제 중복을 제거한다. 위의 예제에서는 중복이 잘 보이지 않는것 같지만, 사실은 다음과 같은 중복이 있음을 알 수 있다.
int amount = 5 * 2; // new Dollar(5)와 five.times(2)에서 나왔던 5와 2가 중복하여 등장하고 있다.
따라서 다음과 같이 리팩토링하여 중복을 제거한다.
int amount;
Dollar(int amount){
this.amount = amount;
}
void times(int multiplier){
// amount = 5 * 2;
// amount = amount * 2;
// amount = amount * multiplier;
// amount *= multiplier;
}
그럼 우리는 적어도 다섯가지 문제사항 중 두번째는 해결한 것이다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10- amount를 private으로 만들기
- Dollar 부작용?
- Money 반올림?
2장 타락한 객체
우리의 목적은 작동하는 깔끔한 코드 를 얻는것이다.
이를 위해서 먼저 '작동하는' 부분에 집중하고, 그 다음에 '깔끔한' 부분을 해결한다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10- amount를 private으로 만들기
- Dollar 부작용?
- Money 반올림?
다섯가지 문제 사항 중 4번에 집중해보자.
public void testMultiplication(){
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, product.amount);
five.times(3);
assertEquals(15, product.amount);
}
달러를 multiple하는 테스트를 작성했다고 쳤을 때, five.times(2)
를 거치면 Dollar five의 amount는 10으로 바뀐다. 이 상태로 assertEquals를 한번 실행하고, 다음번에 다시 five.times(3)
을 거치면 당연히 five.amount
는 10*3 = 30이 될 것이다. 테스트를 통과하지 못하는 것이다.
따라서 테스트용으로 사용할 five라는 Dollar 객체의 값은 5로 고정되어있어야한다. 그러므로 five를 오염시키지 않기 위해서, 우리는 times()
함수가 새로운 객체를 반환하여야 한다는 것을 유추해낼 수 있다.
따라서 테스트를 다음과 같이 수정한다.
public void testMultiplication(){
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(10, product.amount);
product = five.times(3);
assertEquals(15, product.amount);
}
이렇게 테스트 코드를 수정했다면, Dollar.times()
를 수정하지 않으면 새 테스트는 당연히 수정조차 되지 않을 것이다.
Dollar times(int multiplier) {
amount *= multiplier;
return null; // 테스트가 돌아가도록 하기 위한 정말 최소한의 컴파일만 수행(초록 막대)
}
return 값을 올바르게 고치면, 다음과 같다.
Dollar times(int multiplier){
return new Dollar(amount * multiplier);
}
[테스트 코드의 껍데기를 짜고(빨강)] -> [컴파일만 되도록 고치고(초록)] -> [중복을 제거하며 올바른 값을 내뱉도록 바꾸고(리팩토링)]의 과정이 너무 잘게 쪼개어져 있어서 넘어가기 쉽지만, 이 과정들을 쪼개어 (최소한 머릿속으로) 순차적으로 처리할 수 있는 능력이 저자가 요구하는 TDD 라는 생각이 든다.
초록을 보기 위해 취할 수 있는 전략 세 가지 중 두 가지는 다음과 같다.
- 가짜로 구현하기 : 상수를 반환하게 만들고 진짜 코드를 얻을 때까지 단계적으로 상수를 변수로 바꾸어 나간다.
- 명백한 구현 사용하기 : 실제 구현을 입력한다.
뭘 입력해야할 지 명확히 알고, 테스트코드도 정확하게 짜고 있다면 후자를 지속한다. 그러다 예상치 못한 '빨강'을 만나면 전자를 사용하여 올바른 코드로 리팩토링한다. 이 두 가지 방법을 때에 맞추어 스왑해가며 사용하자.
3장 모두를 위한 평등
Dollar 객체와 같이 객체를 값처럼 쓸 수 있는데, 이를 값 객체 패턴(value object pattern) 이라고 한다. 값 객체는 생성자를 통해 설정된 이후에는 절대 변하지 않는다. 따라서 값 객체는 그 값이 영원히 하나의 값임을 보장받을 수 있다. 누군가가 새로운 값을 원한다면 새로운 객체를 하나 더 만들어야 할 것이다.
값 객체를 사용했을 때 기억 할 점은 2장에서 다루었던 것과 마찬가지로, 모든 연산이 새 객체를 반환해야 한다는 점과, equals()
를 구현해야 한다는 점이다. 만약 Dollar를 해시 테이블의 키로 쓸 것이라면 equals()
를 구현할 때 hashCode()
도 만들어야 하지만, 일단 그것은 나중에 생각하기로 한다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10- amount를 private으로 만들기
Dollar 부작용?- Money 반올림?
- equals()
- hashCode()
public void testEquality(){
assertTrue(new Dollar(5).equals(new Dollar(5)));
}
equals()
가 없으니 한번 구현해보자.
public boolean equals(Object object){
return true; // 우리 목표는 equals가 true를 반환하도록 만드는 것이므로, 정말 true만 반환시킨다(물론 나중에 리팩토링 할것이다)
}
결국 equals()
가 반환하는 true는 다음을 의미한다는 것을 모두 알 수 있다.
'5 == 5'
'amount == 5'
'amount == dollar.amount'
삼각측량법
삼각 측량법
라디오 신호를 두 수신국이 감지하고 있을 때, 두 수신국 사이의 거리가 알려져있고, 각 수신국이 신호의 방향을 알고 있다면 그것만으로 충분히 신호의 거리와 방향을 알 수 있다. 이것을 삼각측량이라고 이야기한다.
테스트 코드에서 삼각 측량법은 예제가 두 개 이 상 있어야 일반화할 수 있으므로, 새로운 예제를 만들어 보자.
$5 = $6
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
assertFalse(new Dollar(5).equals(new Dolars(6)));
}
이제 equals를 리팩토링하여 동치성(equality)를 테스트하는 일반화 테스트 코드로 바꾼다. 같은 타입의 객체로 바꾸어 동등한 입장에서 비교한다.
public boolean equals(Object object){
Dollar dollar = (Dollar) object;
return amount = dollar.amount;
}
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10- amount를 private으로 만들기
Dollar 부작용?- Money 반올림?
equals()- hashCode()
times()
를 리팩토링 할 때도 삼각측량을 사용할 수 있다. 예제가 $5 * 2 = $10
, $5 * 3 = $15
였다면 단순히 상수를 리턴해서는 테스트를 통과할 수 없었을 것이다. 따라서 두 예제를 모두 해결하는 '일반화 된 모델'을 짤 수 밖에 없게 만든다.
저자가 이야기하는 삼각측량은 이런 느낌인 듯 하다. 삼각측량에서 두 개의 수신국 정보를 가지고 특정 신호의 방향성을 찾아 내듯이, 두 개(혹은 N개)의 예제를 이용하여 특정 테스트코드의 방향성(즉 일반화)을 이끌어내는 방법 이라는 생각이 들었다.
삼각 측량은 두 예제에 대하여 테스트 코드를 하나씩, 총 두 번 짜는 기법이므로 어떻게 생각하면 비효율적인 방식이다(다른 방식에 비해서). 그럼에도 불구하고 삼각측량을 사용해야 하는 경우는 어떻게 리팩토링(또는 설계)해야 할 지 전혀 모를 때 인데, 이 때 삼각 측량은 현재 프로그램이 지원해야 할 '변화 가능성', 즉 방향 을 세우는 데에 도움을 준다. 여러 예제에 맞추어 몇 몇 부분을 변경하다 보면 어떤 사항들을 다 커버해야 할 것인지 감이 올 것이다.
값 객체 두개를 비교하도록 하는 동일성 문제 는 해결되었지만, 널 값이나 다른 객체들과 비교할 때에는 equals()
함수에 보충할 부분이 아직 있을 수 있다. 일단은 할일 목록에 적어두기만 하자.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10- amount를 private으로 만들기
Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
3장에서 다룬 내용은 다음과 같다.
- 값 객체를 사용한다면 새로운 오퍼레이션을 구현해야한다( 여기서는
equals()
) - 해당 오퍼레이션을 테스트하고, 간단히 구현한다.
- 삼각 측량법에 따라 두 경우를 모두 수용할 수 있을 정도로 리팩토링한다.
4장 프라이버시
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10- amount를 private으로 만들기
Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
public void testMultiplication(){
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(10, product.amount);
product = five.times(3);
asssertEquals(15, product.amount);
}
Dollar.times()
연산은 호출받은 Dollar 객체 값에 인자로 받은 곱수만큼 곱한 값을 갖는 Dollar를 반환해야 한다. 그럼 assertEquals()
에서 불필요한 부분을 리팩토링 해볼 수 있다.
public void testMultiplication(){
Dollar five = new Dollar(5);
assertEquals(new Dollar(10, five.times(2));
assertEquaals(new Dollar(15). five.times(3));
}
불필요한 product 변수를 걷어냈으므로, Dollar의 amount 변수를 public에서 사용하지 않아도 되게 되었다. 따라서, amount 변수를 private으로 선언할 수 있다.
private int amount;
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
한 가지를 해결했지만, 주의할 점이 있다. 3장에서 만들었던 equals()
라는 동치성 테스트가 제대로 작동하지 않는다면, 새로 조정한 테스트코드가 정확하게 작동한다는 것을 보장할 수 없다는 것이다. equals가 두 값 객체를 올바르게 비교한다는 가정 하에 10
과 product.amount
를 걷어낸 것이기 때문이다.
이번 장에서 배운것은 다음과 같다.
- 개발된 기능은 테스트의 개선을 위해서만 사용했다.(
Dollar.times()
) - 두 테스트가 동시에 실패하면, 망한다.(
equals()
가 제대로 안 만들어졌다면 망할 것이다) - 위험요소가 있음에도 계속 진행한다.(그럼에도 불구하고 테스트코드를 짰다면 코드는 전진한다)
- 테스트와 코드 사이에 결합도를 낮추기 위해, 테스트하는 객체의 새 기능을 사용했다. -> 테스트코드에서
amount
를 사용했기 때문에amount
를 private 변수로 만들 수 없다면, 테스트와 코드가 너무 결합된 것이다. 따라서Dollar.times()
의 새 기능(값 객체를 반환)을 활용하여 결합도를 낮추었다.
댓글