본 글은 켄트 백의 <테스트 주도 개발>을 읽고 개인적으로 정리한 내용입니다. 내용에 오류가 있을 시 지적해주시면 감사하겠습니다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
- 5CHF * 2 = 10CHF
Dollar 객체를 비슷하게 본딴 Franc 객체를 만들어본다. 이전의 Dollar 테스트에서 코드를 가져온다.(재사용성!)
public void testFrancMultiplication() {
Franc five = new Franc(5);
assertEquals(new Franc(10), five.times(2));
assertEquals(new Franc(15), five.times(3));
}
이제 이전에 Dollar 테스트가 겪었던 길을 그대로 따라간다(복붙!)
class Franc {
private int amount;
Franc(int amount) {
this.amount = amount;
}
Franc times(int multiplier) {
return new Franc(amount * multiplier);
}
public boolean equals(Object object){
Franc franc = (Franc) object;
return amount == franc.amount;
}
}
컴파일은 일단 성공!
그러나 이전에 만들었던 Dollar 코드와 중복이 많기 때문에, 다음 장에서는 중복을 고쳐서 재 작성할 것이다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
- 공용 Equals
- 공용 times
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
- 공용 Equals
- 공용 times
Franc
이라는 새로운 객체를 만들어 테스트코드를 작성했지만, 복붙하는 과정에서 Dollar
객체와 중복이 너무 많이 생겨버렸다.
따라서 두 객체의 중복되는 코드를 공통 상위 클래스 Money
의 코드로 뺀 후, Dollar
와 Franc
이 모두 이 클래스를 상속받게 할 것이다.
class Money{
protected int amount; // 하위 클래스(Dollar, Franc)에서도 변수를 볼 수 있도록 가시성을 private -> protected로 변경
}
class Dollar extends Money {
}
amount
를 Money
의 필드로 뺐다면, 다음은 equals
코드를 빼 올릴 차례다.
// Money
public boolean equals(Object object) {
Money money = (Money) object;
return amount == money.amount;
}
위의 과정은 축약해서 한번에 적었지만, 책에서는 Dollar
객체 내에서 타입 캐스팅 하나, 변수명 하나를 바꿀때마다 테스트코드에 이상이 없는지 돌려가며 수행하고, Money
의 메서드로 완전히 리팩토링했다고 생각될 때 Money
로 옮긴다. 정말 차근차근 조금씩 하는 셈.
Money
에 일반화된 equals
의 코드가 있으므로, 이제 Franc
의 equals
를 제거해야한다.
그런데 이전에 만들어뒀던 동치성 테스트( testmultiplication
)가 Franc
객체에 대해서는 비교하고 있지 않으므로, Franc
도 테스트 할 수 있도록 테스트코드를 추가한다. (모든 테스트코드는 내부 로직을 테스트하는것이 아니라 외부로 드러나는 결과-그래서 equals인거야?-에 대해서 테스트한다. 외부, 외부.)
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
assertFalse(new Dollar(5).equals(new Dollar(6)));
assertTrue(new Franc(5).equals(new Franc(5)));
assertFalse(new Franc(5).equals(new Franc(6)));
/*
중복된 assertTrue, assertFalse 코드에 대해서는 추후에 리팩토링한다.
*/
}
Franc
의 equals
도 Dollar
와 마찬가지로 리팩토링 한 후, 상위 클래스 Money
의 equals
와 다른 점이 없음을 확인하고 제거한다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals- 공용 times
- Franc와 Dollar 비교하기
*서로 다른 과일인 사과와 오렌지와 같이, 서로 다른 것을 비교할 수 없음을 이르는 관용어구이다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals- 공용 times
- Franc와 Dollar 비교하기
만약에, Franc
과 Dollar
를 비교한다면 어떤 결과가 일어날까?
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
assertFalse(new Dollar(5).equals(new Dollar(6)));
assertTrue(new Franc(5).equals(new Franc(5)));
assertFalse(new Franc(5).equals(new Franc(6)));
assertFalse(new Franc(5).equals(new Dollar(5)));
}
당연히 java 객체상 Franc
와 Dollar
는 다른 객체이므로 실패한다.
이를 명시적으로 하여, 두 통화(Currency)를 비교하여 다르면 동치성 비교를 통과하지 못하도록 코드를 작성하자.
public boolean equals(Object object) {
Money money = (Money) object;
return amount == money.amount && getClass().equals(money.getClass());
}
아직 통화(Currency)의 개념이 없으므로, equals
메소드는 두 객체(통화)의 클래스가 완전히 동일해야 한다는 조건을 추가한다. 그러나 이는 자바 코드(클래스)적인 측면이지, 재정 개념적으로 명확한 비교가 아니다. 일단 당장에 통화 개념을 만들어야 할 필요성은 없으니 일단 할일 목록에 추가해 둔다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals- 공용 times
Franc와 Dollar 비교하기- 통화?
이제 다음장에서는 공통의 times()
코드를 처리해보기로 한다. 따라서 혼합된 통화의 연산을 다룰것이다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals- 공용 times
Franc와 Dollar 비교하기- 통화?
// Franc code
Franc times(int multiplier) {
return new Franc(amount * multiplier)
}
// Dollar code
Dollar times(int multiplier) {
return new Dollar(amount * multiplier)
}
현재 Franc
과 Dollar
의 times()
코드는 개념적으로 완전히 동일하다.(복붙의 폐해..)
// Franc code
Money times(int multiplier) {
return new Franc(amount * multiplier)
}
// Dollar code
Money times(int multiplier) {
return new Dollar(amount * multiplier)
}
양쪽 모두 Money
객체를 반환하도록 만들었는데, 두 객체의 times 메서드를 다 삭제하고 상위에서 리팩토링하는것은 효율적인 TDD가 아닌 것 같다.(야금야금, 살금살금!)
Money
클래스의 하위 클래스인 Dollar
, Franc
클래스의 직접적인 참조를 피하기 위해서, Money
객체 내부에 하위 클래스 객체를 반환하도록 팩토리 메서드를 만들면 어떨까?
// Money 코드
// Money 팩토리 메서드로, dollar 객체를 반환
static Dollar dollar(int amount) {
return new Dollar(amount);
}
public void testMultiplication() {
Money five = Money.dollar(5); // 원래 코드는 Dollar five = new Dollar(5);
assertEquals(new Dollar(10), five.times(2));
assertEquals(new Dollar(15), five.times(3));
}
그러나 이렇게 바꾸었을 경우에, Money.times()
가 존재하지 않으므로 에러를 뱉는다. 따라서 Money를 추상 클래스로 변경하여, Money.times()
를 선언해준다.
// Money 코드
abstract class Money
abstract Money times(int multiplier);
// 팩토리 메서드의 선언을 Money로 바꿔준다.
static Money dollar(int amount) { // 원래 코드는 static Dollar dollar(int amount)
return new Dollar(amount);
}
// 마찬가지로 Franc도 동일하게 작성한다.
static Money franc(int amount) { // 원래 코드는 static Franc franc(int amount)
return new Franc(amount);
}
이제 기존 테스트 코드에서 Dollar, Franc 하위 객체 직접 호출을 리팩토링해준다.
public void testMultiplication() {
Money five = Money.dollar(5);
assertEquals(Money.Dollar(10), five.times(2));
assertEquals(Money.Dollar(15), five.times(3));
}
public void testEquality() {
assertTrue(Money.dollar(5).equals(Money.dollar(5)));
assertFalse(Money.dollar(5).equals(Money.dollar(6)));
assertTrue(Money.franc(5).equals(Money.franc(5)));
assertFalse(Money.franc(5).equals(Money.franc(6)));
assertFalse(Money.franc(5).equals(Money.dollar(5)));
}
이제 Money의 하위 클래스 Dollar, Franc의 존재를 테스트에서 완전히 분리했다.따라서 상속구조를 마음대로 변경할 수 있게 되었다(이게 어떤 말인지 아직 정확히 이해가 가지는 않는다). 테스트 코드에서 하위 클래스의 존재를 분리한다는 것은 중요한 개념이다. 팩토리 메서드를 사용하여 하위 클래스를 (외부) 테스트 코드에서 떼어내는 것을 잘 기억해두자.
그런데 이전에 만들어두었던 testFrancMultiplication()
이 커버하는 부분이, Dollar에 대한 곱하기 테스트코드인 testMultiplication()
과 동일한 로직을 수행한다는 것을 알 수 있다. 하위 클래스의 존재 자체를 테스트 코드에서 떼내려고 하는데, 불필요하게 하위 클래스에 대한 테스트 코드가 남아있는 셈이다.
public void testFrancMultiplication() {
Franc five = new Franc(5);
assertEquals(new Franc(10), five.times(2));
assertEquals(new Franc(15), five.times(3));
}
이 코드를 리팩토링하여 삭제할지 어쩔지는, 할일 목록에 일단 추가해두도록 하자. 아직 코드에 대한 완전한 확신은 없으니까..
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHFDollar/Franc 중복공용 Equals- 공용 times
Franc와 Dollar 비교하기- 통화?
- testFrancMultiplication을 지워야할까?
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals- 공용 times
Franc와 Dollar 비교하기- 통화?
- testFrancMultiplication 제거
통화 개념의 테스트코드를 짜기 위해서, 통화를 표현하는 객체들이 필요하다. 각각 구현할수도 있겠지만, 일단은 문자열을 사용해서 표현한다.
public void testCurrency() {
assertEquals("USD", Money.dollar(1).currency());
assertEquals("CHF", Money.franc(1).currency());
}
Franc와 Dollar 두 클래스에 같은 currency()
함수를 구현하기 위해서, 인스턴스 변수에 문자열을 저장한 후 메서드에서 그것을 반환하게 만든다.
// Money 코드
abstract String currency();
// Franc 코드
private String currency;
Franc(int amount) {
this.amount = amount;
currency = "CHF";
}
String currency() {
return currency;
}
// Dollar 코드
private String currency;
Dollar(int amount) {
this.amount = amount;
currency = "USD";
}
String currency() {
return currency;
}
두 하위클래스의 currency()
가 동일하고, 인스턴스 변수를 선언하는 것도 동일하므로 이 두 부분을 상위 클래스로 올릴(push up) 수 있게 되었다.
// Money 코드
protected String currency;
String currency() {
return currency;
}
'USD'와 'CHF' 문자열을 정적 팩토리 메서드로 옮긴다면 두 생성자는 동일해질거고, 공통 구현을 만들 수 있을 것이다.
// Franc 코드
// Franc의 생성자
Franc(int amount, String currency) {
this.amount = amount;
this.currency = "CHF";
}
생성자를 인자에 추가하였다.
생성자에 인자를 추가했으니, 당연히 생성자를 호출하는 부분들이 깨진다. 깨지는 부분은 다음과 같다.
// Money 코드
// #1
static Money franc(int amount) {
return new Franc(amount, null);
}
// Franc 코드
// #2
Money times(int multiplier) {
return new Franc(amount * multiplier, null);
}
#2에서 times()
가 팩토리 메서드를 호출해야하는데, 생성자를 호출하고 있다. 따라서 생성자를 팩토리 메서드로 정리해준다.
// Franc 코드
// #2
Money times(int multiplier) {
return Money.franc(amount * multiplier);
}
이제 #1에서 Money의 팩토리메서드 franc()
이 "CHF"를 전달하도록, null값을 바꾸어주면된다.
// Money 코드
// #1
static Money franc(int amount) {
return new Franc(amount, "CHF");
}
그러고 나면 비로소 생성자 Franc이 "CHF"를 인자로 자동으로 전달받도록 되었으므로, 인스턴스 내부에서 "CHF"를 생성하여 넘겨주지 않아도 된다. 인자로 들어온 currency를 그대로 넘겨주면 되는 것이다.
// Franc 코드
// Franc의 생성자
Franc(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
이처럼 잘게 [constant, 문자열 등을 넘겼다가] - [공통 부분을 push up하고] - [내부의 하드코딩된 코드들을 리팩토링해가는 것]이 거부감이 들긴 한다. 전체적으로 보면 나도 이런 과정과 비슷하게 코드를 짤테지만, 이걸 의식적으로 항상 따라하기에는 무리가 있는것같다. 저자는 '이런식으로 하라'는 것이 아니라 __'이런식으로 할 줄도 알아야한다'__라는 입장이다.
Dollar도 동일한 방식으로 변환한다.
// Money 코드
// 팩토리메서드 dollar
static Money dollar(int amount) {
return new Dollar(amount, "USD"); // 팩토리메서드 dollar는 생성자의 currency에 항상 "USD"를 넘겨줌(통화를 바꾸고 싶다면 여기만 고치면 돼!)
}
// Dollar 코드
Dollar(int amount, String currency) {
this.amount = amount;
this.currency = currency; // 인자를 이용하여 currency 인스턴스변수를 선언
}
Money times(int multiplier) {
return Money.dollar(amount * multiplier); // 생성자를 팩토리메서드로 대체
}
두 하위 클래스의 구현이 비슷해졌으므로 , 구현을 상위 클래스로 올린다.
// Money 코드
Money(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
// Franc 코드
Franc(int amount, String currency) {
super(amount, currency);
}
// Dollar 코드
Dollar(int amount, String currency) {
super(amount, currency);
}
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals- 공용 times
Franc와 Dollar 비교하기통화?- testFrancMultiplication 제거
지금까지 Dollar와 Franc의 중복 문제를 해결해주려다가, 작은 문제들(각 메서드, 변수들의 push up)을 많이 맞닥뜨렸다. 하위클래스 생성자들의 다른 부분들을 일치시키고 이를 push up했다. 또, times()
가 생성자가 아닌 팩토리메서드를 사용하도록 만들었다(상위클래스로 times()
를 옮기기 위한 발판이다) 이제 times()
를 상위클래스로 올리면 하위 클래스를 제거 할 준비가 거의 다 된 것 같다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals- 공용 times
Franc와 Dollar 비교하기통화?- testFrancMultiplication 제거
이번 장에서 Money를 나타내는 하위 클래스를 모두 제거하고, Money 클래스만 남겨둘 것이다(리팩토링 완성!)
일단 두 하위클래스의 times()
함수를 비교해보자.
// Franc 코드
Money times(int multiplier) {
return Money.franc(amount * multiplier);
}
// Dollar 코드
Money times(int multiplier) {
return Money.dollar(amount * multiplier);
}
살펴보아도 두 함수를 동일하게 만들 방법이 명확하지 않다. 이럴 때는 다시 뒤로 돌아가본다. 팩토리 메서드를 인라인시켜본다(문맥상 생성자 코드로 재작성한다는 이야기인 듯 하다).
// Franc 코드
Money times(int multiplier) {
return new Franc(amount * multiplier, "CHF");
}
// Dollar 코드
Money times(int multiplier) {
return new Dollar(amount * multiplier, "USD");
}
이렇게 바꾸고 보니, Franc과 Dollar는 모두 같은 이름의 인스턴스 변수 currency를 가지고 있는데, 마침 "CHF", "USD"와 맞아떨어진다. 하드코딩된 "CHF", "USD"를 동일한 currency로 바꿔본다.
// Franc 코드
Money times(int multiplier) {
return new Franc(amount * multiplier, currency);
}
// Dollar 코드
Money times(int multiplier) {
return new Dollar(amount * multiplier, currency);
}
그럼 이제 코드에서 다른 부분은 생성자 부분 (new Franc
와 new Dollar
)밖에 없게 되었다. 이걸 둘다 Money로 바꿔도 잘 작동할까..?
심사숙고에서 추리할수도 있지만, 우리는 테스트코드를 미리 짜놨으니 한번 돌려보는 것만으로 결과를 확인할 수 있다!
// Franc 코드
Money times(int multiplier) {
return new Money(amount * multiplier, currency);
}
Money가 지금 abstract class(추상 클래스)인데, concrete class(구상 클래스)로 바꾸라는 컴파일러의 메시지가 뜬다.
// Money 코드
class Money
Money times(int amount) {
return null;
}
Money의 오퍼레이션이 구현체를 가지도록 최소한의 times코드를 짰다.
돌리고 나니 빨간막대(에러메시지)가 뜨는데, 메시지가 뭐라고하는지 잘 모르겠다.
// Money 코드
// 객체 정보를 표시해주는 스트링함수
public String toString() {
return amount + " " + currency
}
이 코드를 짠 후 테스트를 돌려보니, 에러 메시지가 다음과 같이 뜬다.
expected:<10 CHF> but was:<10 CHF>
뭐지? 같은건데 다르다고? 살펴보니 클래스가 다르단다. 앞의 <10 CHF>는 Franc인데, 실제로 들어간 값인 뒤의 <10 CHF>는 Money란다.
// Franc 코드
Money times(int multiplier) {
return new Money(amount * multiplier, currency);
}
아까 times()
가 Money를 반환하도록 코드를 짰으니 뒤의 건 맞는데, 앞의 Franc <10 CHF>는 어디서 나왔을까?
테스트코드에서 오류가 떴으니 비교에서 오류가 났을텐데... 그렇다면 equals()
를 살펴보자.
// Money 코드
public boolean equals(Object object) {
Money money = (Money) object;
return amount = money.amount && getClass().equals(money.getClass());
}
getClass()
로 클래스를 비교함으로써 통화를 비교했다는 착각에 빠질 수 있지만, 실제로는 그렇지 않다. Money(10, 'CHF')와 Franc(10, 'CHF')는 선언한 클래스의 위치만 다를 뿐이지. 내부의 currency는 'CHF'로 같다. 즉 같은 통화인 것이다. 그런데 위의 코드는 클래스를 비교하고 있으므로, 해당 코드를 currency 비교로 고쳐야할 것이다.
그럼 문제의 원인을 파악했으니 바로 고칠까?
보수적으로 보면 그렇게 하면 안된다. 테스트코드는 빨강
막대인 경우에 실제 모델 코드를 고치지 않는다. 불확실성 위에 불확실성을 덧붙이는 셈이기 때문이다. 실제 모델 코드를 건드리려면 원인을 먼저 파악하고, 초록
막대에서 시작해야한다(단단한 기반에서 시작한다고 생각하자). 빨강
막대를 보기 직전 코드로 돌아가자.
// Franc 코드
Money times(int multiplier) {
return new Franc(amount * multiplier, currency);
}
그럼 다시 여기서 우리가 파악한 문제점-다른 클래스라도 currency가 같다면 같은 통화로 취급해야한다는 것-을 그대로 테스트코드로 짜본다.
public void testDifferentClassEquality() {
assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF")));
}
위의 코드는 실패한다. 아직 실제 모델 코드가 currency가 아닌 클래스를 비교하고 있기 때문이다. 테스트의 fail을 확인하고 실제 모델 코드를 변경한다.
// Money 코드
public boolean equals(Object object) {
Money money = (Money) object;
return amount = money.amount && currency().equals(money.currency());
}
이제 두 하위 클래스에서 리턴값의 클래스를 Money로 바꾸어도 제대로 동작할 것이다.
// Franc 코드
Money times(int multiplier) {
return new Money(amount * multiplier, currency);
}
// Dollar 코드
Money times(int multiplier) {
return new Money(amount * multiplier, currency);
}
두 구현이 동일해졌으니, 상위 클래스로 push up 할 수 있다.
// Money 코드
Money times(int multiplier) {
return new Money(amount * multiplier, currency);
}
이제 하위 클래스 Franc와 Dollar에서 하는 모든 오퍼레이션을 상위 클래스로 끌어올렸으니, Franc와 Dollar라는 하위 클래스들을 충분히 제거할 수 있게 되었다.
- $5 + 10CHF = $10(환율이 2:1일 경우)
$5 * 2 = $10amount를 private으로 만들기Dollar 부작용?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF * 2 = 10CHF- Dollar/Franc 중복
공용 Equals공용 timesFranc와 Dollar 비교하기통화?- testFrancMultiplication 제거