[TDD] Test-Driven-Development(테스트 주도 개발) : #2

Junho Bae·2021년 4월 5일
1

TDD

목록 보기
1/1

Test-Driven-Develop TDD

“테스트 주도 개발” - 켄트 백님의 책을 읽고 정리한 내용입니다. 약간의 수정이 필요합니다.

8. 객체 만들기

할 일 목록

  • $5+10CHF = $10
  • ~$5 x 2 = $10~
  • ~ammount를 private으로 만들기~
  • ~Dollar 부작용?~
  • Money 반올림?
  • ~equals~
  • hashCode()
  • Equal null
  • Equal object
  • ~5CHF x 2 = 10CHF~
  • Dollar/Franc 중복
  • ~공용 equaks~
  • 공용 times
  • ~Franc과 Dollar 비교하기~
  • 통화?

현재까지의 구현 상황을 보면, Franc과 Dollar의 time 메서드 구현은 매우 비슷하며 Money로 반환타입을 명시하면 더욱 비슷하게 만들 수 있습니다.

이제 Money의 하위 클래스가 딱히 하는일이 없어보이기 때문에, 이제 이들을 제거해보려고 합니다. 우선, 두 하위 클래스에 대한 참조를 줄이는 것이 시작일 것입니다.

이를 구현하는 팩토리 메소드는 다음과 같습니다.

public static Dollar dollar(int amount) {
        return new Dollar(amount);
    }

이제 Dollar를 참조하는 부분역시 바뀌어야 합니다.

public void testMultiplication() {
        Money five = Money.dollar(5);
		assertEquals(new Dollar(10), five.times(2));
        assertEquals(new Dollar(15), five.times(3));
    }
}

이러면 이제 컴파일러가 Money는 time이 없다는 사실을 알려주지만, 아직 구현은 하지 않고 Money를 abstract으로 변경한 후 메서드를 선언해줍니다. 이제 팩토리 메서드의 선언 역시 Money로 바꿔줄 수 있습니다.

public abstract class Money {
	
	public static Money dollar(int amount) {
        return new Dollar(amount);
    }
	
	....

	abstract Money times(int multiplier);
}

테스트가 깨지지 않기 때문에, 이제 이 팩토리 메서드를 테스트 코드의 나머지에서 활용할 수 있습니다. Franc 역시도 Dollar처럼 팩토리 메서드로 만들어 주고, 테스트코드에서 하위 클래스들의 참조를 줄여봅시다.

 	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)));
    }
    
    public void testFrancMultiplication() {
        Money five = Money.franc(5);
        assertEquals(Money.franc(10), five.times(2));
        assertEquals(Money.franc(15), five.times(3));
    }

testFrancMultiplication을 살펴보면, Dollar 곱하기 로직에 의해 테스트되지 않는 부분이 없습니다. 하지만, 일단은 남겨두도록 하고 넘어가겠습니다.

이번 장의 결론

  • times라는 동일한 메서드의 두 현이형 메서드 서명부를 통일 시킴으로써 중복 제거에 한걸음 더 전진
  • 메서드 선언부분이라도 상위 클래스로
  • 팩토리 메서드를 통해 테스트 코드에서 하위클래스 존재 사실을 분리
  • 하위클래스가 사라지니 몇몇 테스트가 불필요해보이지만 일단 두기.

9. 우리가 사는 시간

할 일 목록

  • $5+10CHF = $10
  • ~$5 x 2 = $10~
  • ~ammount를 private으로 만들기~
  • ~Dollar 부작용?~
  • Money 반올림?
  • ~equals~
  • hashCode()
  • Equal null
  • Equal object
  • ~5CHF x 2 = 10CHF~
  • Dollar/Franc 중복
  • ~공용 equaks~
  • 공용 times
  • ~Franc과 Dollar 비교하기~
  • 통화?
  • testFrancMultiplication 제거

위의 할일 목록에서, 통화의 개념을 도입하여 불필요한 하위 클래스를 제거해 보겠습니다.

자, 그렇다면 통화 개념을 어떻게 구현하길 원하는가? 아차, 실수. 다시 해보자. 회초리가 나오기 전에 말을 바꿔야겠다. 자, 그렇다면 통화 개념을 어떻게 테스트하길 원하는가? 됐다. 일단 매는 피했다. - p.94

테스트를 작성해야 합니다. 일단 복잡한 객체 그리고 경량 팩토리 대신 단순 문자열을 사용할 것입니다.

  public void testCurrency() {
        assertEquals("USD", Money.dollar(1).currency());
        assertEquals("CHF", Money.franc(1).currency());
    }

currency()에서 당연히 컴파일 에러가 나겠죠? 해당 메서드를 작성해 줍시다. 우선 Money에 선언을 하고, 하위 클래스에서 구현해봅시다.


//Money Class
public abstract String currency();

//Franc Class
public String currency() {
        return "CHF";
    }

//Dollar Class
public String currency() {
        return "USD";
    }

사실, 이렇게 할 필요 없이 클래스에 인스턴스 변수로 선언을 해서 그걸 리턴하는게 더 좋겠죠?

본문에서는 사고의 흐름에 따라 진행을 합니다.

  1. 각각의 클래스에 인스턴스 변수를 선언하고 해당 값을 바로 초기화 해주고 이를 리턴하는 메소드를 만들자.
  2. 공통되는 부분을 상위 클래스로 옮기고, currency를 protected로 선언하자.
  3. 이제 문자열을 정적 팩토리 메소드로 옮긴다면, 구현이 동일화 될 거니까 생성자에 인자를 추가해주자. -> 중 생성자에 인자를 추가하고 있는데,

생성자 추가를 위해서 클래스를 보자니, times 메서드가 지금 팩토리 메서드를 호출하는 것이 아닌 생성자를 호출하고 있음을 알 수 있습니다. 저자는 원칙적으로는 하던 일을 마무리 하고 돌아오는 것이 맞지만, 보통 짧은 중단이 필요한 경우에는 흔쾌히 받아들인다고 합니다. 단, 중단하고 있는 와중 또 중단을 하지는 않습니다. 따라서, times를 먼저 정리하겠습니다.

  public Money times(int multiplier) {
        return Money.franc(multiplier);
    }

현재 Money의 franc 팩토리 메서드는 multiplier로 받고 currency를 null로 처리합니다.

이제, 팩토리 메서드가 각각의 currency를 전달할 수 있습니다.

//Money
public static Money franc(int amount) {
        return new Franc(amount,"CHF");
    }

이제 구현을 상위 클래스에 옮기면 됩니다.


//Money
public Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

//Dollar
public Dollar(int amount, String currency) {
        super(amount,currency);
    }

//Franc
public Franc(int amount,String currency) {
        super(amount, currency);
    }

내가 이런 작은 단계를 밟아나가는 것에 대해서 다시금 방어적으로 되는 것 같다. 내가 여러분에게 정말 이런식으로 일해야 한다고 주장하는건가? 아니다. 나는 여러분이 이런 식으로 일할 수도 있어야 한다고 말하는 것이다.
지금과 같은 일은 TDD를 하는 동안 계속 해주어야 하는 일종의 조율이다. 종종걸음으로 진행하는 것이 답답한가? 그러면 보폭을 조금 넓혀라. 성큼성큼 걷는 것이 불안한가? 그럼 보폭을 줄여라. TDD란 조종해 나가는 과정이다. 이쪽으로 조금, 저쪽으로 조금. 지금도, 그리고 앞으로도 정해진 올바른 보폭이라는 것은 존재하지 않는다.

이제 우리는 times()를 상위 클래스로 올리고 하위 클래스들을 제거할 준비가 거의 다 되었습니다. 하지만 일단은 지금까지 한 것을 검토해 보면,

  • 큰 설계 아이디어를 다루기 위해 더 작은 작업을 수행
  • 다른 부분들을 팩토리 메서드로 옮김으로써 두 생성자를 일치
  • times()가 팩토리 메서드를 사용하도록 만들기 위해 리팩터링 잠시 중지
  • 비슷한 리팩터링을 한번에 큰 단계로 수행 (Franc에서 했던걸 Dollar에서)
  • 동일한 생성자 상위 클래스로 이동

10. 흥미로운 시간

현재 times의 구현은 Franc와 Dollar 모두 비슷하기는 하지만 같지는 않습니다.

//Dollar
public Money times(int multiplier) {
    return Money.dollar(amount*multiplier);
}

//Franc
public Money times(int multiplier) {
    return Money.franc(amount*multiplier);
}

비록 방금 전에 이 부분을 팩토리 메서드로 바꿨지만, 다시 인라인으로 호출로 변경합니다.

//Dollar
public Money times(int multiplier) {
	return new Dollar(amount*multiplier, currency);;
}

//Franc
public Money times(int multiplier) {
    return return new Franc(amount*multiplier,currency);
}

두 클래스 모두 currency를 가지고 있고 이는 무조건 “USD”(Dollar의 경우), “CHF”(Franc)의 경우 이기 때문에 위와 같이 쓸 수 있습니다.

그럼 이제, Franc을 가질지, Money를 가질지가 중요할까요? 그냥 둘다 Money를 가진다면 공통으로 빼버릴 수 있지 않을까요?

이제 시스템에 대한 고민을 해야겠지만, 우리에게는 잘 짜여진 테스트 코드와 깔끔한 코드가 있습니다. 본인이 고민하지 말고, 그냥 컴퓨터에게 던져줍니다.

  1. Franc.times()가 Money를 반환하도록 고칩니다.

//Franc
public Money times(int multiplier) {
        return new Money(amount*multiplier,currency);
    }

컴파일러가 Money는 콘크리트 클래스여야 한다고 합니다.

  1. Money 수정.
  • Money를 구체 클래스로 바꿔주고
  • 간단하게 times 메서드를 null로 반환하게 만들어 줍니다.

이제 돌려보면 빨간막대가 뜨는데 이를 더 잘 알아보기 위해 toString을 작성해 줍니다. 하지만 이는 디버그 출력에만 사용되기 때문에 이게 잘못 구현된다고 해도 리스크가 적고, 이미 빨간 막대인데 또 새로운 테스트를 쓰는 것이 부담스럽기 때문에, 바로 작성을 해줍니다.

다시 에러 메시지를 확인하면,
expected:<10 CHF> but was <10 CHF> 를 확인할 수 있습니다. 이는 답은 맞았는데 클래스가 다르다고 나옵니다. equals의 구현을 살펴봅니다.

  1. equals 수정
    여기서 우리가 진짜로 검사해야 할 것은, 클래스가 같은 지가 아니라 currency가 같은지 여부입니다.

하지만, 지금 우리는 빨간 막대인 상황입니다. 이런 상황에서 테스트를 추가로 작성하는 것은 바람직하지 않습니다. 하지만 지금은 실제 모델 코드를 수정하려고 하는 중이고, 테스트 없이는 모델 코드를 수정할 수 없습니다. 보수적인 방법에 따르면, 변경된 코드를 되돌려서 다시 초록 막대 상태로 돌아가야 합니다.

그리고 나서 equals를 위한 테스트 코드를 고치고, 구현 코드를 고치고, 구 후에야 하려던 일을 다시 할 수 있습니다.

나는 때때로 그냥 앞으로 밀고 나아가서 빨간 막대 상태에서도 테스트를 새로이 하나 작성하지만, 아이들이 깨있는 동안에는 그렇게 안한다. 애들이 배우면 안되니까. -p.105

//Franc
public Money times(int multiplier) {
        return new Franc(amount*multiplier,currency);
    }

이제 초록 막대입니다. 우리는 Franc(10, “CHF”)와 Money(10,”CHF”)가 같기를 바라지만 그러지 않습니다. 이대로 테스트 코드를 작성합니다.

public void testDifferentClassEquality() {
        assertTrue(new Money(10, "CHF").equals(new Franc(10,"CHF")));
    }

예상대로 실패합니다. 현재 equals는 currency가 아니라 getClass()를 통해 비교하고 있기 때문입니다.

    public boolean equals(Object object) {
        Money money = (Money) object;
        return money.amount == amount && currency().equals(money.currency());
    }

이제 Franc.times()에서 Money를 반환하더라도 테스트가 여전히 통과합니다.
Dollar에도 적용합니다.

//Franc
public Money times(int multiplier) {
        return new Money(amount*multiplier,currency);
    }

//Dollar
public Money times(int multiplier) {
        return new Money(amount*multiplier, currency);
    }

됩니다. 이제 상위 클래스로 옮깁니다.

이제, 아무것도 안하는 멍청한 하위클래스들을 제거할 수 있습니다.

결론
1. 두 times()를 일치시키기 위해, 그 메서드들이 호출하는 다른 메서드들을 인라인 시킨 후 상수를 변수로 변경
2. 디버깅을 위해 테스트 없이 toString 작성
3. Franc 대신 Money를 반환하도록 변경 시도, 작동할지를 테스트를 통해 판단.
4. 실험을 뒤로 물리고 또다른 테스트를 작성. 테스트 작동, 실험 작동.

11. 모든 악의 근원

이제 두 하위 클래스에는 달랑 생성자 밖에 남지 않았습니다. 코드의 의미를 변경하지 않으면서 하위 클래스에 대한 참조를 상위 클래스에 대한 참조로 바꾸도록 리팩토링 합니다.


//Money
 public static Money dollar(int amount) {
    return new Money(amount,"USD");
   }

 public static Money franc(int amount) {
    return new Money(amount,"CHF");
   }

이제 Dollar에 대한 참조는 남아있지 않기 때문에 제거할 수 있씁니다. 다만, Franc은 아직 테스트 코드에서 참조하고 있습니다.

 public void testDifferentClassEquality() {
        assertTrue(new Money(10, "CHF").equals(new Franc(10,"CHF")));
    }

이 테스트를 지워도 될 정도로 다른 곳에서 동치성 테스트를 하고 있는지 확인해봅니다.

 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)));
    }

충분하다 못해 과하기 때문에, 세 번째와 네 번째를 지웁니다.

현재 testDifferentClassEquality는 강제로 클래스 대신 currency를 비교하도록 하는 테스트 입니다. 이는 여러 클래스를 비교할 때에나 의미가 있는 거지, 지금처럼 Franc을 삭제하려는 경우에는 도움이 되질 않습니다. 같이 날려버립니다.

testFrancMultiplication 역시 비슷하게, 클래스가 Franc, Dollar일 때는 의미가 있었지만 지금은 없습니다. 현재 로직에는 차이가 없기 때문입니다. 날려도 큰 신뢰의 손실이 없습니다.

결론

  • 하위 클래스의 속을 들어내는 것을 완료, 하위 클래스 삭제
  • 불필요하게된 테스트 삭제.

12. 드디어, 더하기

나는 손으로 할일 목록을 옮겨 적는걸 즐긴다. 이 때, 자그마한 항목이 많으면 그걸 옮기기보다 그냥 처리해버리는 경향이 있다. 나는 게으르기 때문에, 그대로 두면 계속 누적될 만한 자잘한 일들을 처리해 버린다. 자신의 강점을 살리는 쪽으로 하면 된다.

할 일

  • $5 + 10CHF = $10 (환율이 2:1인 경우)
    전체 더하기 기능의 스토리가 방대하니까, 일단 더 간단한
  • $5 + $5 = $10에서 시작해 봅시다.

테스트 코드 작성

public void testSimpleAddition() {
    Money sum = Money.dollar(5).plus(Money.dollar(5));
    assertEquals(Money.dollar(10),sum);
  }

//Money
public Money plus(Money added) {
      return new Money(amount+ added.amount,currency);
  }

물론 가짜 구현을 할 수도 있습니다. 저자는 이제부터 가짜 구현과 명확한 구현을 하면서 어떻게 TDD의 보폭을 조절하는지 살펴보라고 합니다.

현재, 설계에서 가장 어려운 제약은, 다중 통화 사용에 대한 내용을 시스템의 나머지 코드에게 숨기고 싶은 점 입니다.

전부 참조통화로 변환해서 사용하는 것도 있지만, 이 방법으로는 여러 환율을 쓰기가 쉽지 않습니다.

객체

편하게 여러 환율을 표현할 수 있으면서도 산술 연산 비슷한 표현들을 여전히 산술 연산처럼 다룰 수 있는 해법? ~개인적으로 이 부분이 약간 난해했는데, 목표가 무엇인지 생각해야 합니다.~

임포스터, 수식, 은행,Expression

가지고 있는 객체가, 우리가 원하는 방식으로 작동하지 않을 경우 외부 프로토콜은 동일한데 내구 구현이 다른 새로운 객체, 즉 임포스터를 만듭니다.

약간 신기해 할지도 모르겠다. 여기에 임포스터를 만들 생각을 한다는 걸 어떻게 알 수 있을까? 번뜩이는 설계상의 착상을 가능케 해주는 공식 같은 건 없다. 농담이 아니다.이 기교는 워드 커닝엄이 십년 전에 만들었는데…본적이 없기 떄문에..(중략)..TDD는 적절한 때에 번뜩이는 통찰을 보장하지 못한다. 그렇지만 확신을 주는 테스트와 조심스럽게 정리된 코드를 통해, 통찰에 대한 준비와 함께 통찰이 번뜩일 때 그걸 적용할 준비를 할 수 있다.-p.115

Money와 비슷하게 동작하지만 사실은 두 Money의 합을 나타내는 객체를 만드는 겁니다. 이를 위해 두 가지 아이디어가 필요합니다.

  1. 지갑과 같이, 금액과 통화가 다른 여러 화폐들이 들어갈 수 있는 메타포
  2. 수식. (2+3)x5와 같은 수식. 즉, Money가 가장 작은 단위가 되는겁니다.

이 수식의 경우, 수식 연산의 결과로 Expression들이 생길 것이고, 그중 하나는 Sum이 될 수 있습니다. 연산이 완료되면 (지갑의 금액들이 더해지거나 등) 환율을 이용해서 결과 Expression을 단일 통화로 축약할 수 있습니다.

테스트 코드를 하나하나 완성해 보면,

    public void testSimpleAddition() {
		....
       assertEquals(Money.dollar(10),reduced);
    }

reduced라는 친구는 Expression에 환율을 적용한 것입니다. 환율은 은행에서 적용되져?

    public void testSimpleAddition() {
        Money reduced = bank.reduced(sum, "USD");
       assertEquals(Money.dollar(10),reduced);
    }

여기서 아주 중요한 결정이 일어납니다. 왜 sum.reduce(“USD”,bank)가 아니라 위와 같이 작성이 되었을까요. 즉, 왜 bank가 환율을 적용하는 reduce의 책임을 갖게 하여 분리했을까요?

  1. 제일 먼저 생각난다. 환율은 은행이 해야지. 헷
  2. Expression은 우리가 하는 일의 핵심입니다. 저자는 핵심이 되는 객체가 다른 부분에 대해서 될 수 있는 한 모드도록 노력하여, 핵심 객체가 가능한 오랫 동안 유연할 수 있게 합니다. 그렇게 하면 테스트하기도 쉽고, 재활용하거나 이해하기 쉽게 남길 수 있습니다.
  3. Expression과 관련된 오퍼레이션이 많을 수도 있기 떄문입니다.

다시 테스트 코드

물론 은행이 필요 없다면, 그 책임을 Expression으로 언제든 옮겨도 괜찮습니다.

public void testSimpleAddition() {
        Money five = Money.dollar(5);
        Expression sum = five.plus(five);
        Bank bank = new Bandk();
        Money reduced = bank.reduced(sum, "USD");
       assertEquals(Money.dollar(10),reduced);
    }

맨 아래 코드부터 하나씩 위로 만들어 가는 흐름
이제 Money.plus는 Expression을 반환해야 합니다. 그렇다면 Money는 Expression을 구현해야 합니다. 그리고 빈 Bank도 필요합니다.

그리고 빈 Bank에는 reduce의 stub이 필요합니다.

다 만들면 돌아가고 드디어 실패합니다!

13. 진짜로 만들기

1) 데이터 중복 없에기

public class Bank {
    Money reduce(Expression source, String to) {
        return Money.dollar(10);
    }
}
    public void testSimpleAddition() {
        ...
        Expression sum = five.plus(five);
      	...
    }

우선, Money.plus()는 Expression(특히 Sum)을 반환해야 하기 떄문에 그 합은 Sum이어야 합니다.

이에 대한 테스트 코드를 작성합니다.

    public void testPlusReturnsSum() {
        Money five= Money.dollar(5);
        Expression result = five.plus(five);
        Sum sum = (Sum) result;
        assertEquals(five, sum.augend);
        assertEquals(five, sum.augend);
    }

이와 맞는 Sum 클래스를 작성합니다.

public class Sum {
    Money augend;
    Money addend;
}

이렇게 바꾸면, 클래스 익셉션이 뜹니다. Money.plus()의 반환형을 Sum으로 수정합니다. 컴파일에러를 쭉 따라갑니다. Sum의 생성자도 필요하며, Expression의 일종이어야 합니다.

    public Expression plus(Money addend) {
        return new Sum(this, addend);
    }
//Sum
public class Sum implements Expression {
    Money augend;
    Money addend;

    public Sum(Money augend, Money addend) {
        this.augend = augend;
        this.addend = addend;

    }
}

이제 Bank.reduce()는 Sum을 전달받기 때문에, 만약 Sum이 가지고 있는 Money의 통화가 모두 동일하고 reduce를 통해 얻어내는 Money의 통화 역시 같다면, 결과는 Sum의 내부 Moneye들의 amount를 합친 값을 갖는 Money 객체여야 합니다.

즉, 통화가 다 같다면 그 안을 다 더한걸 가지고 있어야 한다는 뜻이죠. 이 말을 죽 풀어서 테스트 코드로 쓴다면 다음과 같습니다.

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);
    }

이제 Sum을 계산하고 나면, 그 결과는 Money가 되어야 하며, 그 Money의 양은 두 Money amount의 합이고, 통화는 우리가 축약하는 통화여야 합니다.

public class Bank {

    Money reduce(Expression source, String to) {
        Sum sum = (Sum) source;
        int amount = sum.augend.amount + sum.addend.amount;
        return new Money(amount,to);
    }
}

두 가지 문제로 지저분합니다.

  • 캐스팅 (Expression에 대한)
  • 공용 필드와 그 필드들에 대한 두 단계에 걸친 레퍼런스(sum.augend.amount…)

일단 외부 접근 가능 필드 몇개를 들어내기 위해 메서드 본문을 Sum으로 옮깁니다.

//Bank

public class Bank {

    Money reduce(Expression source, String to) {
        Sum sum = (Sum) source;
        return sum.reduce(to);
    }
}

//Sum

public class Sum implements Expression {
    Money augend;
    Money addend;

    public Sum(Money augend, Money addend) {
        this.augend = augend;
        this.addend = addend;

    }

    public Money reduce(String to) {
        int amount = augend.amount + addend.amount;
        return new Money(amount,to);
    }
}

이제 초록 막대를 볼 수 있으며, 더 할게 있나 싶으니, Bank.reduce()의 인자로 Money를 넘기는 경우의 테스트를 작성합니다.

 public void testReduceMoney() {
        Bank bank = new Bank();
        Money result = bank.reduce(Money.dollar(1), "USD");
        assertEquals(Money.dollar(1), result);
    }

public class Bank {

    Money reduce(Expression source, String to) {
        
        if(source instanceof Money) return (Money) source;
        Sum sum = (Sum) source;
        return sum.reduce(to);
    }
}

클래스를 명시적으로 검사하는 코드가 있을 때에는, 항상 다형성을 사용하도록 바꾸는 것이 좋습니다. Sum은 reduce를 구현하기 때문에, Money도 그걸 구현하도록 만든다면, reduce를 Expression 인터페이스에 추가할 수 있게 됩니다.

public class Bank {

    Money reduce(Expression source, String to) {

        if(source instanceof Money) return (Money) source.reduce(to);
        Sum sum = (Sum) source;
        return sum.reduce(to);
    }
}

//Money class
public Money reduce(String to) {
        return  this;
    }

//Expression
public interface Expression {
    Money reduce(String to);
}

//Bank Again
public class Bank {

    Money reduce(Expression source, String to) {
        return source.reduce(to);
    }
}

매우 깔끔해졌습니다. 다만, Expression과 Bank에 이름만 같고 매개 변수형이 다른 메서드가 있는 것이 조금 꺼림직 합니다.

14. 바꾸기

2프랑이 있는데 이걸 달러로 바꾸고 싶습니다.
바로 테스트 코드를 작성합니다.

    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);
    }

일단, 대충 프랑을 달러로 바꾸는데 /2를 합니다. 일단 합니다.

//Money
public Money reduce(String to) {
        int rate = (currency.equals("CHF") && to.equals("USD"))? 2 : 1;
        return new Money(amount/rate,to)
    }

이렇게 되고 나니, 갑자기 Money가 환율을 처리합니다. 그러면 안대져, 환율은 Bank가 처리해야 합니다. Expression.reduce()의 인자로 Bank를 넘겨야 할 겁니다. 이제 호풀 부분을 바꿉니다.

일단 reduce의 선언부에 Bank가 호출되어야 하고, 인터페이스에 선언된 메서드는 공용이어야 하기 때문에 Money의 reduce도 공용이어야 합니다. 이제 환율을 Bank에서 계산합니다.


//Bank
int rate(String from, String to) {
        return (from.equals("CHF") && to.equals("USD"))? 2 : 1;
    }

//Money

public Money reduce(Bank bank, String to) {
        int rate = bank.rate(currency, to);
        return new Money(amount/rate,to);
    }

이제 자꾸 이상한 2가 계속 있으니까, 이걸 없애 버리고 Bank에서 환율 표를 가지고 있다가 필요할 떄 찾아봐야 합니다. 해시 테이블을 매핑으로 사용합니다.

통화 쌍을 해시 테이블의 키로 쓰기 위해 배열을 사용할 수 있을지, Array.equals()가 가각의 원소에 대한 동치성 검사를 수행하는지 확인합니다.

public void testArrayEquals() {
        assertEquals(new Object[] {"abc"}, new Object[] {"abc"});
    }

실패합니다. 안하나 봅니다. 키를 위한 객체를 만들어야 합니다. 이걸 키로 쓸 거니까 equals, hashCode()를 구현합니다.

public class Pair {
    
    private String from;
    private String to;
    
    Pair(String from, String to) {
        this.from = from;
        this.to = to;
    }

    public boolean equals(Object object) {
        Pair pair = (Pair) object;
        return from.equals(pair.from) && to.equals(pair.to);
    }
    
    public int hashCode() {
        return 0;
    }
}

0은 최악이지만 일단 테스트를 위해 빨리 달립니다. 끝으로, Bank에서도 환율을 저장할 무언가와 설정할 수 있는 것이 필요합니다.

    private Hashtable rates = new Hashtable();
    
    void addRate(String from, String to, int rate) {
        rates.put(new Pair(from,to), new Integer(rate));
    }
    
    int rate(String from, String to) {
        Integer rate = (Integer) rates.get(new Pair(from,to));
        return rate.intValue();
    }

빨간 막대를 확인해보면, USD에서 USD로의 환율을 요청하면 그 값이 1이되어야 한다는 기대를 알 수 있습니다. 이제 이걸 남겨두기 위해 테스트로 만들어줍니다.

public void testIdentityRate() {
        assertEquals(1, new Bank().rate("USD","USD"));
    }

//Bank

int rate(String from, String to) {
        if (from.equals(to)) return 1;
        Integer rate = (Integer) rates.get(new Pair(from,to));
        return rate.intValue();
    }

통과.

15. 서로 다른 통화 더하기

이제서야, 5$ + 10CHF 에 대한 테스트를 추가할 수 있게 되었습니다.


public void testMixedAddition() {
        Expression fiveBucks = Money.dollar(5);
        Expression tenFrancs = Money.franc(10);

        Bank bank = new Bank();
        bank.addRate("CHF", "USD",2);

        Money result = bank.reduce(fiveBucks.plus(tenFrancs),"USD");
        assertEquals(Money.dollar(10),result);
    }

컴파일 에러들을 해결해야 합니다. Money -> Expression의 일반화를 지금까지 얼렁뚱땅 해왔기 떄문에..

일단 위의 돈들을 Money로 바꿔줍니다.

public void testMixedAddition() {
        Money fiveBucks = Money.dollar(5);
        Money tenFrancs = Money.franc(10);

        Bank bank = new Bank();
        bank.addRate("CHF", "USD",2);

        Money result = bank.reduce(fiveBucks.plus(tenFrancs),"USD");
        assertEquals(Money.dollar(10),result);
    }

테스트가 실패하며 15USD가나옵니다. Sum.reduce()가 실제로 인자를 축약하고 있지 않기 때문입니다.


public Money reduce(Bank bank, String to) {
        int amount = augend.amount + addend.amount;
        return new Money(amount,to);
    }

//After

public Money reduce(Bank bank, String to) {
        int amount = augend.reduce(bank,to).amount+ addend.reduce(bank,to).amount;
        return new Money(amount,to);
    }

통과합니다. 사실, Expression이어야 하는 Money들을 조금씩 없앨 수 있는데, 파급효과를 위해 가장자리부터 해서 테스트코드까지 갑니다.

피가산수와 가산수는 이제 Expression 취급이 가능하기 때문에 Sum을 수정합니다.

public class Sum implements Expression {
    Expression augend;
    Expression addend;

    public Sum(Expression augend, Expression addend) {
        this.augend = augend;
        this.addend = addend;

    }

Money의 plus 인자, times의 반환 값도 Expression일 수가 있습니다.

//Money
    public Expression times(int multiplier) {
        return new Money(amount*multiplier, currency);
    };

    public Expression plus(Expression addend) {
        return new Sum(this, addend);
    }

이제 테스트 케이스의 인자도 바꿔줍니다.

    public void testMixedAddition() {
        Expression fiveBucks = Money.dollar(5);
        Expression tenFrancs = Money.franc(10);

        Bank bank = new Bank();
        bank.addRate("CHF", "USD",2);

        Money result = bank.reduce(fiveBucks.plus(tenFrancs),"USD");
        assertEquals(Money.dollar(10),result);
    }

fiveBucks를 Expression으로 바꿔주고 난후 컴파일러가 하나는걸 쭉 해갑니다.

1) Expression에 plus()정의
2) Money의 plus를 public으로
3) Sum의 구현을 스텁으로. 할일목록 추가.

이제, Money를 Expression으로 일반화할 수 있습니다.

결론
1) 원하는 테스트를 작성, 한 단계에 달성할 수 있도록 물러서기
2)좀더 추상적인 선언을 통해 가지에서 뿌리로 일반화
3)변경후 영향 받은 부분들 변경

16. 드디어, 추상화

할일 목록
Sum.plus
Expression.times

이제 Sum.plus()를 구현하여 Expression.plus()를 끝내고, Expression.times()를 예제 끝입니다.

당연히 sum plus의 테스트코드부터 작성합니다.

    public void testSumPlusMoney() {
        Expression fiveBucks = Money.dollar(5);
        Expression tenFrancs = Money.franc(10);
        
        Bank bank = new Bank();
        bank.addRate("CHF","USD",2);
        
        Expression sum = new Sum(fiveBucks,tenFrancs).plus(fiveBucks);
        
        Money result = bank.reduce(sum,"USD");
        assertEquals(Money.dollar(15),result);
        
    }
  • 두 Expression을 더해서 Sum을 만드는 대신, 명시적 Sum 선언. 독자를 위해서.
  • 테스트 코드가 코드보다 더 길다…. 코드는 Money 코드랑 똑같다…
    public Expression plus(Expression addend) {
        return new Sum(this,addend);
    }

sum.plus()완료.

TDD로 구현할 땐 테스트 코드의 줄 수와 모델 코드의 줄 수가 거의 비슷한 상태로 끝난다. TDD가 경제적이기 위해서는 매일 만들어 내는 코드의 줄 수가 두배가 되거나 동일한 기능을 구현한되 절반의 줄 수로 해내야 한다.

이제, Sum.times()가 작동만 하면, 마지막인 Expression.times()를 선언하는 일이야 어렵지 않습니다. 일단 테스트.

    public void testSumTimes() {
        Expression fiveBucks = Money.dollar(5);
        Expression tenFrancs = Money.franc(10);
        
        Bank bank = new Bank();
        bank.addRate("CHF","USD",2);
        
        Expression sum = new Sum(fiveBucks,tenFrancs).times(2);
        Money result = bank.reduce(sum,"USD");
        assertEquals(Money.dollar(20),result);
    }

이번에도 테스트가 코드보다 깁니다. 이제 코드를 작성합니다.


Expression times(int multiplier) {
        return new Sum(augend.times(multiplier), addend.times(multiplier));
    }
  • 이후 Expression에 times 추가
  • 가시성 높이기

그리고, 깔끔하게 정리하기위해 $5 + $5 가 Money를 반환하는지 실험.

    public void testPlusSameCurrencyReturnsMoney() {
        Expression sum = Money.dollar(1).plus(Money.dollar(1));
        assertTrue(sum instanceof Money);
    }

이 테스트코드는 구현 보면 구현 중심이라서 좀 지저분하지만, 단지 실험일 뿐더러 우리가 원하는 변화를 가할 수 있게 해줍니다.

근데 버립니다. 인자가 Money일 경우 필요충분조건으로 인자의 통화를 확인하는 분명하고 깔끔한 방법이 없으니, 실험은 실패했고 코드를 삭제하고 떠납니다.

결론

  • 미래의 독자를 염두한 코드 작성
  • TDD와 현재 나의 개발 스타일을 비교해볼 수 있는 실험방법 제시
  • 선언부에 대한 수정이 나머지 시스템 부분으로 번져나가 컴파일러를 따라 수정
  • 실험 버림.

17. Money 회고

  • 이제 다음 할일은?
  • 메타포 : 설계 구조에 미치는 메타포의 엄청난 영향
  • JUnit 사용도
  • 코드 메트릭스 : 결과 코드의 수치화
  • 프로세스 : 빨강,초록,리팩토링 각각 얼마 만큼 작업하나
  • 테스트의 질

이제 다음 할 일?

Sum.plus()와 Money.plus()사이의 지저분한 중복이 남아있습니다. Expression이 인터페이스에서 클래스로 바뀐다면, 공통 코드를 담을 수 있을 것입니다.

  • TDD는 완벽을 위한 노력의 일환으로 사용하면 효과적이지 않습니다. 만약 내가 만드는 시스템이 크다면, 내가 건드리는 부분들은 절대적으로 견고해야 합니다. 그래야 나날이 수정할 때 안심할 수 있기 떄문입니다. 가장자리로 흘러갈 때마다 테스트는 이쁘지 않을 수 있지만 안심할 수 있습니다.
  • 다음에 할일은 무엇인가? 는 “어떤 테스트들이 추가로 필요할까”입니다. 때로 실패해야 하는 테스트 케이스가 성공할 때에는 그 이유를 반드시 찾아내야 합니다.
  • 할일 목록이 빌 때가 그 때까지 설계한 것을 검토하기에 적절한 시기입니다!

메타포

메타포가 생각보다 설계에 어마어마한 영향을 끼쳤습니다.
~난좀 난해한 것 같던뎅..~

JUnit 사용도

코드 매트릭스

  • 코드와 테스트 코드 사이에 대략 비슷한 양의 함수와 줄이 있씁니다.
  • ㅁ회기성 복잡도는 기존의 흐름 복잡도와 같습니다. 테스트 코드에 분기나 반복문이 전혀 업시 때문에 테스트 복잡도가 1입니다. 명시적 흐름 대신 다형성을 주로 사용하여 실제 코드의 복잡도도 낮습니다.

프로세스

TDD의 주기

  • 작은 테스트르를 추가한다
  • 모든 테스트를 실행하고, 실패하는 것을 확인한다.
  • 코드에 변화를 준다.
  • 모든 테스트를 실행하고, 성공하는 것을 확인한다.
  • 중복을 제거하기 위해 리팩토링한다.

테스트의 질

테스트들은 유용하지만, 다음의 테스트들을 대체하지는 않습니다.

  • 성능 테스트
  • 스트레스 테스트
  • 사용성 테스트
profile
SKKU Humanities & Computer Science

0개의 댓글