11. 모든 악의 근원
TestFrancMultiplication 제거
static Money dollar(int amount){
return new Money(amount, "USD");
}
static Money franc(int amount){
return new Money(amount, "CHF");
}
- 팩토리 매서드의 리턴을 상위 클래스로 변경하면서 하위 클래스에 대한 참조를 줄였다.
- 이렇게 되면 Dollar에 대한 참조는 다 사라진다, 하지만 여전히 Franc에 대한 참조는 있는 상태
Franc를 참조하는 테스트 코드 제거
@Test
public void testEquality(){
assertTrue(Money.dollar(5).equals(Money.dollar(5)));
assertFalse(Money.dollar(5).equals(Money.dollar(6)));
assertFalse(Money.franc(5).equals(Money.dollar(5)));
}
- Franc를 참조하는 testDifferentClassEquality 테스트와 동치성 테스트인 testEquality에 있는 단언 몇 개를 제거했다.
우리는
- 하위 클래스의 속을 들어내는 걸 완료하고, 하위 클래스를 삭제했다.
- 기존의 소스 구조에서는 필요했지만 새로운 구조에서는 필요 없게 된 테스트를 제거했다.
12. 드디어, 더하기
@Test
public void testSimpleAddition(){
Money sum = Money.dollar(5).plus(Money.dollar(5));
assertEquals(Money.dollar(10), sum);
}
Money plus(Money addend){
return new Money(amount + addend.amount, currency);
}
- Money 클래스에 plus 메서드를 만들어주자.
다중 통화 연산을 어떻게 표현할 것인가?
- Money와 비슷하게 동작하지만 사실은 두 Money의 합을 나타내는 객체를 만든다.
- 이를 구현하기 위해 Money의 합을 마치 지갑처럼 취급해보자. 한 지갑에는 금액과 통화가 다른 여러 화폐들이 들어갈 수 있다.
- 또는 (2+3) * 5 같은 수식이다. 우리 경우에는 ($2 + 3CHF)x5가 되겠지만, 이렇게 하면 Money를 수식의 가장 작은 단위로 볼 수 있다.
- 연산의 결과로 Expression들이 생기는데, 그 중 하나는 합이 될 것이다. 연산(포트폴리오의 값을 합산하는 것 등)이 완료되면, 환율을 이용해서 결과 Expression을 단일 통화로 축약시킬 수 있다.
@Test
public void testSimpleAddition(){
Bank bank = new Bank();
Money reduced = bank.reduce(sum, "USD");
assertEquals(Money.dollar(10), reduced);
}
- reduced란 이름의 식은 식에 환율을 적용함으로써 얻어진다. 환율이 적용되는 곳은 은행이므로 위와 같이 테스트 코드를 작성했다.
완성된 테스트 코드
@Test
public void testSimpleAddition(){
Money five = Money.dollar(5);
Expression sum = five.plus(five);
Bank bank = new Bank();
Money reduced = bank.reduce(sum, "USD");
assertEquals(Money.dollar(10), reduced);
}
- 두 Money의 합은 Expression 이어야 한다.
- $5는 팩토리 메서드로 만들었다.
객체 생성
public class Bank {
Money reduce(Expression source, String to){
return null;
}
}
public interface Expression {
}
public class Money implements Expression{
...
Expression plus(Money addend){
return new Money(amount + addend.amount, currency);
}
}
- Money.plus()는 Expression을 구현해야 한다. 일단 인터페이스를 구현하게끔 Money 코드를 수정하자.
- bank에는 reduce의 스텁(소프트웨어 개발에 쓰이고 다른 프로그래밍 기능을 대리하는 코드)이 있어야 한다.
- 컴파일이 되고 실패한다. 간단히 reduce에 10 달러를 리턴하게끔 수정해보자
Money reduce(Expression source, String to){
return Money.dollar(10);
}
우리는
- 큰 테스트를 작은 테스트 ($5 + 10CHF에서 $5 + $5로) 줄여서 발전을 나타낼 수 있도록 했다.
- 우리에게 필요한 계산에 대한 가능한 메타포들을 신중히 생각해봤다.
- 새 메타포에 기반하여 기존의 테스트를 재작성했다.
- 테스트를 빠르게 컴파일 했다.
- 그리고 테스트를 실행했다.
-진짜 구현을 만들기 위해 필요한 리팩토링을 하였다.
13. 진짜로 만들기
만들어보자
public class Bank {
Money reduce(Expression source, String to){
return Money.dollar(10);
}
}
@Test
public void testSimpleAddition(){
Money five = Money.dollar(5);
Expression sum = five.plus(five);
Bank bank = new Bank();
Money reduced = bank.reduce(sum, "USD");
assertEquals(Money.dollar(10), reduced);
}
- 코드 중복은 없지만 데이터 중복이 있다. Bank의 10 달러와, 테스트 코드에 있는 five.plus(five) 부분이 같다.
@Test
public void testSimpleAddition(){
Money five = Money.dollar(5);
Expression result = five.plus(five);
Sum sum = (Sum) result;
assertEquals(five, sum.augend);
assertEquals(five, sum.addend);
}
- 우선 Money.plus()는 그냥 Money가 아닌 Expression(Sum)을 반환해야 한다. 즉 두 Money의 합은 Sum이여야 한다.
- 이 테스트를 통과하기 위해 Sum을 구현해보자
public class Sum implements Expression{
Money augend;
Money addend;
Sum(Money augend, Money addend){
this.augend = augend;
this.addend = addend;
}
}
Expression plus(Money addend){
return new Sum(this, addend);
}
- Sum을 구현하고 Money를 반환하는 Expression.plus도 수정하자.
- 이제 Bank.reduce()는 Sum을 전달받는다. 만약 Sum이 가지고 있는 Money의 통화가 모두 동일하고, reduce를 통해 얻어내고자 하는 Money 통화도 같으면 결과는 Sum 내에 있는 Money들의 amount를 합친 값을 갖는 Money 객체여야 한다.
@Test
public void testReduceSum(){
Expression sum = new Sum(Money.dollar(3), Money.dollar(4));
Bank bank = new Bank();
Money result = bank.reduce(sum, "USD");
assertEquals(Money.dollar(7), result);
}
- 위와 같이 작성할 수 있다. 이제 10달러만 반환하게 임시로 만들어 둔 Bank.reduce()를 수정해보자.
Bank
Money reduce(Expression source, String to){
Sum sum = (Sum) source;
return sum.reduce(to);
}
- Sum에서 reduce를 구현하지 않아서 컴파일이 안된다. 마찬가지로 구현해주자
Sum
public Money reduce(String to){
int amount = augend.amount + addend.amount;
return new Money(amount, to);
}
추가로 테스트 작성
@Test
public void testReduceMoney(){
Bank bank = new Bank();
Money result = bank.reduce(Money.dollar(1), "USD");
assertEquals(Money.dollar(1), result);
}
- 막대가 초록색이고 위의 코드에 대해 더 할 것이 명확하지 않으니 추가로 테스트를 작성하자.
- ClassCastException이 발생하면서 빨간 막대 상태가 된다.
코드 수정
public interface Expression {
Money reduce(String to);
}
- Expression 인터페이스에 reduce 메서드를 추가한다.
Money
@Override
public Money reduce(String to) {
return this;
}
Bank
Money reduce(Expression source, String to){
return source.reduce(to);
}
- Money에 reduce를 오버라이딩하여 구현한 다음, Bank의 reduce에서 source의 reduce를 반환하게 수정한다.
우리는
- 모든 중복이 제거되기 전까지는 테스트를 통과한 것으로 치치 않았다.
- 구현하기 위해 역방향이 아닌 순방향으로 작업했다.
- 앞으로 필요할 것으로 예상되는 객체(Sum)의 생성을 강요하기 위한 테스트를 작성했다.
- 빠른 속도로 구현하기 시작했다. (Sum의 생성자)
- 일단 한 곳에 캐스팅을 이용해서 코드를 구현했다가, 테스트가 돌아가자 그 코드를 적당한 자리로 옮겼다.
- 명시적인 클래스 검사를 제거하기 위해 다형성을 사용햇다.
14. 바꾸기
Money에 대한 통화 변환을 수행하는 Reduce
@Test
public void testReduceMoneyDifferentCurrency(){
Bank bank = new Bank();
bank.addRate("CHF", "USD", 2);
Money result = bank.reduce(Money.franc(2), "USD");
assertEquals(Money.dollar(1), result);
}
- 통화 변경을 구현하기 위해 위와 같은 테스트를 작성하자
@Override
public Money reduce(String to) {
int rate = (currency.equals("CHF") && to.equals("USD")) ? 2: 1;
return new Money(amount / rate, to);
}
- 그 후 테스트를 통과시키기 위해 Money에 환율을 하드코딩 하여 작성했다.
- 환율에 대한 것은 모두 Bank 가 처리해야 한다 Expression에 인자로 Bank를 주도록 수정하자.
public interface Expression {
Money reduce(Bank bank, String to);
}
public class Bank {
Money reduce(Expression source, String to){
return source.reduce(this, to);
}
}
public class Sum {
...
public Money reduce(Bank bank, String to){
int amount = augend.amount + addend.amount;
return new Money(amount, to);
}
}
public class Money{
...
@Override
public Money reduce(Bank bank, String to) {
int rate = (currency.equals("CHF") && to.equals("USD")) ? 2: 1;
return new Money(amount / rate, to);
}
}
- Expression에 Bank 인자를 추가하면서 위와 같이 코드를 수정했다.
Bank
public int rate(String from, String to){
return (from.equals("CHF") && to.equals("USD")) ? 2: 1;
}
Money
@Override
public Money reduce(Bank bank, String to) {
int rate = bank.rate(currency, to);
return new Money(amount / rate, to);
}
- Bank에 환율에 관한 메서드를 추가하고 Money.reduce()에서 bank.rate()를 호출해 환율에 대한 코드를 제거했다.
- 하지만 여전히 2 (환율)이 하드코딩 되어있는 상태다.
환율표
- 하드코딩을 제거하기 위해 두 개의 통화와 환율을 매핑시키는 환율표를 해시 테이블로 구현을 할 것이다.
@Test
public void testArrayEquals(){
assertEquals(new Object[] {"abc"}, new Object[] {"abs"});
}
- 테스트는 당연히 실패한다. 두 객체는 동일한 객체가 아니기 때문이다
해시 테이블 키
public class Pair {
private String from;
private String to;
Pair(String from, String to){
this.from = from;
this.to = to;
}
public int hashCode(){
return 0;
}
}
- 먼저 해시 테이블의 키 값이 되는 Pair 객체를 만들어준다.
- 일단 hashCode는 0으로 설정해주자.
Bank
private Hashtable rates = new Hashtable();
public void addRate(String from, String to, int rate){
rates.put(new Pair(from, to), new Integer(rate));
}
public int rate(String from, String to){
if (from.equals(to))
return 1;
Integer rate = (Integer) rates.get(new Pair(from, to));
return rate.intValue();
}
- Bank 클래스에서 해시 테이블을 선언하고 addRate 메서드를 정의하준다. rate는 환율이다.
- 그리고 rate 함수에서 해시 테이블을 통해 환율을 얻을 수 있게 코드를 수정해준다.
@Test
public void testIdentityRate(){
assertEquals(1, new Bank().rate("USD", "USD"));
}
우리는
- 필요할 거라고 생각한 인자를 빠르게 추가했다. (rate)
- 코드와 테스트 사이에 있는 데이터 중복을 끄집어냈다.
- 자바의 오퍼레이션에 대한 가정을 검사해보기 위한 테스트(testArray-Equals)를 작성했다.
- 별도의 테스트 없이 전용 도우미 클래스를 만들었다.
- 리팩토링하다가 실수를 했고, 그 문제를 분리하기 위해 또 하나의 테스트를 작성하면서 계속 전진했다.