관리 메뉴

너와 나의 스토리

TDD(Test-Driven Development) 연습해보기 - 예제 1: Money (3) 12~17장 본문

개발/TDD(Test-Driven Development)

TDD(Test-Driven Development) 연습해보기 - 예제 1: Money (3) 12~17장

노는게제일좋아! 2021. 3. 11. 22:38
반응형

"테스트 주도 개발 Test-Driven Development: By Example" 책에 나오는 예제를 실제로 구현해보자."

 

<현재까지 작성한 코드>

  • Money.java
public class Money {

    protected int amount;
    protected String currency;

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

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

    String currency() {
        return currency;
    }

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

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

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

    public String toString() {
        return amount + " " + currency;
    }
}
  • MoneyTest.java
public class MoneyTest {

    @Test
    public void testMultiplication() {
        Money five = Money.franc(5);

        assertEquals(Money.franc(10), five.times(2));
        assertEquals(Money.franc(15), five.times(3));
    }

    @Test
    public void testEquality() {
        assertTrue(Money.dollar(5).equals(Money.dollar(5)));
        assertFalse(Money.franc(5).equals(Money.franc(6)));
        assertFalse(Money.franc(5).equals(Money.dollar(5)));
    }

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

}

 

"서로 다른 통화를 더해보자."

  • $5 + 10CHF = $10(환율이 3:1인 경우)
  • 위 기능을 구현하기 전에 간단한 작업부터 시도해보자.
    • 일단 [$5 + $5 = $10]부터 시작해보자.

  • 빨간 막대(오류)를 해결하기 위해 단순히 Money.dollar(10)를 리턴하는 plus() 메서드를 작성할 수 있다.
  • 하지만, 어떻게 구현할지 명확하기 때문에 바로 제대로 구현을 하자.

Money class

  • 다중 통화 연산을 어떻게 표현해야 할까?
    • [(2 + 3) x 5]와 같은 연산부터 해보자.
    • 우리의 경우엔 [($2 + 3CHF) x 5]가 되겠지만, 이렇게 하면 Money를 수식의 가장 작은 단위로 볼 수 있다.
  • 그럼 테스트는 이런식으로 끝날 것이다.
    @Test
    public void testSimpleAddition() {
        // ...       
        assertEquals(Money.dollar(10), reduced);
    }
  • reduced(축약된)란 이름의 Expression은 Expression에 환율을 함으로써 얻어진다. 
  • 실생활에서는 은행에서 환율이 적용되기 때문에 아래와 같이 쓸 수 있겠다.
    @Test
    public void testSimpleAddition() {
        // ...       
        Money reduced = bank.reduce(sum, "USD");
        assertEquals(Money.dollar(10), reduced);
    }
  • 왜 Bank가 reduce()를 수행할 책임을 맡았는가?
    • Expression은 우리가 하려고 하는 일의 핵심이다.
      • 핵심이 되는 객체가 다른 부분에 대해서 될 수 있는 한 모르도록 해야 한다.
      • 그렇게 하면 핵심 객체가 가능한 오랫동안 유연할 수 있다. -> 테스트하기도 쉽고, 코드 재활용하기도 좋음
    • Expression과 관련이 있는 연산이 많을 것 같다.
      • 만약, 모든 연산을 Expression에만 추가한다면 Expression은 무한히 커질 것이다.
    • 혹시 Bank가 별로 필요 없다는 reduce()를 구현할 책임을 Expression으로 옮겨도 좋다.       
  • 테스트 코드를 작성해보자.
    • 두 Money의 합은 Expression이어야 한다.                                                                                                                                  
    @Test
    public void testSimpleAddition() {
        // ...
        Expression sum = five.plus(five);
        Bank bank = new Bank();
        Money reduced = bank.reduce(sum, "USD");
        assertEquals(Money.dollar(10), reduced);
    }
  • $5를 만드는 건 간단하다. Money.dollar()를 호출하면 된다.

  • 이걸 컴파일하도록 해보자.
  • 먼저 Expression 인터페이스가 필요하다.

  • Money.plus()는 Expression을 반환해야 한다.

Money class

  • 즉, Money가 Expression을 구현해야 한다는 뜻이다.

  • 이번에는 Bank 클래스가 필요하다.
    • Bank에는 reduce()의 stub(스텁)이 필요하다.
      • Test Stub: 테스트하는 동안 canned answer을 제공하는 것. 일반적으로 테스트를 위해 프로그래밍된 것 이외의 호출에는 반응하지 않는다. 
        • Canned answer: 정해진 질문에 대해 사전에 준비된 답
        • 호출한 쪽에서 canned answer을 제공

  • 이렇게 하면 컴파일은 되지만, 테스트는 실패한다.

  • 그러면, 테스트가 통과하도록 간단하게 가짜 구현을 해보자.

Bank class

  • 이제 테스트를 성공적으로 통과한다.

 

 

"진짜로 만들기"

  • 가짜 구현에 있는 $10는 테스트 코드에서 [ $5 + $5 ]와 같다.

Bank class

  • 이전에는 가짜 구현이 -> 진짜 구현으로 작업해 가는 것이 명확했다. 단순히 상수를 변수로 치환하는 일이었다.
  • 그렇지만 이번에는 어떻게 작업할지 분명하지 않다. 그래서 일단은 순방향(진짜 구현 -> 가짜 구현)으로 작업해보자.
  • 구현할 것: $5 +$5
  • 우선 Money.plus()는 그냥 Money가 아닌 Expression(Sum)을 반환해야 한다.
  • 두 Money의 합은 Sum이어야 한다.

  • 용어 정리:
    • augend: 덧셈의 첫 인자(피가산수)
    • addend:가수
    • 예: 1+2=3에서 가수는 2이고 피가수(augend)는 1이다.
  • 이 테스트는 수행하고자 하는 연산의 외부 행위(더하기)가 아닌 내부 구현에 대해 너무 깊게 관여하고 있다. 
  • 하지만 이 테스트를 통과하면 목표에 한 걸음 더 다가가게 될 것이다.
  • 이 코드를 컴파일시키기 위해 augend와 addend 필드를 가지는 Sum 클래스가 필요하다.
public class Sum {
    Money augend;
    Money addend;
}
  • Money.plus()는 Sum을 리턴해야 한다.
// Money.java
Expression plus(Money addend) {
	return new Sum(this, addend);
}
  • Sum() 생성자도 필요하다.
// Sum.java
Sum(Money augend, Money addend){

}
  • 그리고 Sum은 Expression의 일종이어야 한다.

  • 이제 컴파일은 되니, 테스트가 통과하게 만들어보자.
  • 우선 Sum 생성자에서 필드를 설정해줘야 한다.
// Sum.java
Sum(Money augend, Money addend) {
  this.augend = augend;
  this.addend = addend;
}
  • 이제 테스트가 통과한다.
  • 다음으로 넘어가자.
    • Bank.reduce()는 Sum을 넘겨받고, 더한 두 통화(augend, addend)가 같다면 결과는 Sum 내에 있는 Money들의 amount를 합친 값을 갖는 Money 객체여야 한다.

  • 물론 bank.reduce()에서 무조건 Money.dollar(10)를 리턴하므로 테스트는 실패한다.
  • bank.reduce()를 고쳐보자.
// Bank.java
Money reduce(Expression source, String to) {
  Sum sum = (Sum) source;
  int amount = sum.augend.amount + sum.addend.amount;
  return new Money(amount, to);
}

 

  • 이 코드는 두 가지 이유로 지저분하다.
    1. 모든 Expression에 대해 캐스팅이 작동해야 한다.
    2. public 필드와 그 필드들에 대한 두 단계에 걸친 레퍼런스
  • 이 문제들을 고쳐보자!
    • 우선, 외부에서 접근 가능한 필드들을 들어내고 메서드 본문을 Sum으로 옮기자.
// Bank.java
Money reduce(Expression source, String to) {
  Sum sum = (Sum) source;
  return sum.reduce(to);
}
// Sum.java
public Money reduce(String to){
  int amount = augend.amount+ addend.amount;
  return new Money(amount, to);
}
  • "Bank.reduce()의 인자로 Money를 전달하는 경우"에 대해 테스트 코드를 작성해보자.
@Test
public void testReduceMoney() {
  Bank bank = new Bank();
  Money result = bank.reduce(Money.dollar(1), "USD");
  assertEquals(Money.dollar(1), result);
}
Money reduce(Expression source, String to) {
  if (source instanceof Money) return (Money) source;
  Sum sum = (Sum) source;
  return sum.reduce(to);
}
  • instanceof: 객체가 특정 클래스에 속하는지 아닌지를 확인.
    • (Object) instanceof (Class Type) -> Object가 Class에 속하거나 Class를 상속받는 클래스에 속하면 true가 반환됨.
    • 예:
Parent parent = new Child();
Child child = (Child) parent; 

parent instanceof Child // true

 

  • 코드가 지저분하쥬? 그래도 테스트는 통과한다 ㅎㅎ
  • 클래스를 명시적으로 검사하는 코드가 있을 때에는 항상 다형성(polymorphism)을 사용하도록 바꾸는 것이 좋다. 
  • Sum은 reduce(String)을 구현하므로, Money에서도 그것을 구현하도록 만든다면 reduce()를 Expression 인터페이스에도 추가할 수 있게 된다.
    • Sum, Money 둘 다 Expression을 구현하는 구조.
// Money.java
public Money reduce(String to) {
	return this;
}

  • 이러면, Bank.reduce()에서 불필요한 캐스팅 작업을 없앨 수 있다.

 

 

 

 

"프랑을 달러로 바꿔보자"

  • [2프랑 = 1달러]라고 가정하자 ㅎㅎ
  • 그렇다면 환율에 맞게 값 변화시키고 통화를 적용해야 한다.

 

// Money.java
public Money reduce(String to) {
  int rate = (currency.equals("CHF") && to.equals("USD"))
  ? 2
  : 1;
  return new Money(amount / rate, to);
}
  • 이렇게 하면, Money가 환율에 대해 알게 된다. 환율은 Bank만 알아야 하는데....
  • 환율에 관한 것들은 Bank가 처리하도록 Expression.reduce()의 인자로 Bank를 넘겨주자.
// Bank.java
Money reduce(Expression source, String to) {
	return source.reduce(this, to);
}
// Expression.java
public interface Expression {
    Money reduce(Bank bank, String to);
}
// bank.java
public Money reduce(Bank bank, String to){
  int amount = augend.amount+ addend.amount;
  return new Money(amount, to);
}
  • 인터페이스에 선언된 메서드 reduce()는 공용이므로 인자를 통일시켜야 한다.
// Money.java
public Money reduce(Bank bank, String to) {
  int rate = (currency.equals("CHF") && to.equals("USD"))
  ? 2
  : 1;
  return new Money(amount / rate, to);
}
  • 이제 bank가 환율을 처리하도록 하자.
// Money.java
public Money reduce(Bank bank, String to) {
  int rate = bank.rate(currency, to);
  return new Money(amount / rate, to);
}
  • bank는 환율표를 가지고 있어야 한다.
  • 두 개의 통화와 환율을 매핑시키는 해시 테이블을 사용할 수 있다.
  • 통화 쌍을 해시 테이블의 키로 쓰기 위해 배열을 사용할 수 있을까?
    • 테스트를 돌려 확인해보자.
    • bank.rate() 때문에 컴파일 에러가 나겠지만, 대충 mocking이나 주석 처리하고 이 테스트부터 돌려보자.

  • 테스트가 실패한다. 
  • 키를 위한 객체를 따로 만들어야겠다.

  • 우린 이 Pair을 키로 쓸 거니까 equals()와 hashCode()를 구현해야 한다.
// Pair.java
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;
    }
}
  • hashCode를 0으로 두면 해시 테이블에서의 검색은 선형 검색과 비슷하게 수행될 것이다. But 일단은 대충 두자.
// Bank.java
public class Bank {

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

    private Hashtable rates = new Hashtable<Pair, Integer>();

    void addRate(String from, String to, int rate) {
        rates.put(new Pair(from, to), rate);
    }

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

}
  • 통화가 같을 때 rate는 1이 되어야 한다. 이를 테스트로 작성해보자.

  • 테스트가 통과한다.

 

 

 

"서로 다른 통화를 더하자"

  • 마지막으로 [$5 +10CHF]를 구현하자.

  • 보다시피 컴파일 에러가 발생한다.
  • 하나씩 고쳐가며 진행해보자.
    @Test
    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);
    }

  • Expression 대신 Money를 이용하니 컴파일 에러는 해결되었으나, 테스트가 실패한다.
  • Sum.reduce()가 인자를 축약하지 않는 것 같아 보인다. 
    // Sum.java
    public Money reduce(Bank bank, String to){
        int amount = augend.reduce(bank, to).amount+ addend.reduce(bank, to).amount;
        return new Money(amount, to);
    }
  • 이렇게 두 인자를 모두 축약했더니 테스트가 통과했다.
  • 하지만 Money 대신 Expression을 써야하므로, Money들을 조금씩 없애보자.
  • 예를 들어 피가산수와 가수는 이제 Expression으로 취급할 수 있다.

 

 

  • Expression에 plus()가 정의되지 않았다.

  • 이제 테스트 통과!!

  

 

추상화

  • To do list:
    1. Sum.plus() 테스트 코드 작성
    2. Sum.plus() 구현
    3. Expression.times() 테스트 코드 작성
    4. Expression.times() 구현
  • Sum.plus() 테스트 코드 작성
    • fiveBucks와 tenFrances를 더해서 Sum을 생성할 수도 있지만, 명시적으로 Sum을 생성해보자.
    @Test
    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);
    }
  • Sum.plus() 구현
    // Sum.java
    public Expression plus(Expression addend) {
        return new Sum(this, addend);
    }
  • Expression.times() 테스트 코드 작성
    @Test
    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() 구현
    // Expression.java
    Expression times(int multiplier);
    // Sum.java
    public Expression times(int multiplier) {
        return new Sum(augend.times(multiplier), addend.times(multiplier));
    }
    // Money.java
    public Expression times(int multiplier) {
        return new Money(amount * multiplier, currency);
    }
  • 테스트 통과!!

 

 

 

 

 

출처:

- [테스트 주도 개발 Test-Driven Development: By Example"]

 

 

반응형
Comments