티스토리 뷰

나의 공부방

[개발서적] Test-Driven Development: By Example(테스트 주도 개발) 내용 정리 - 예시 (1)

망나니개발자 2021. 4. 19. 14:49
반응형

0. 들어가기


테스트 주도 개발(TDD, Test-Driven Development)의 궁극적인 목표는 작동하는 깔끔한 코드의 작성이다. 작동하는 깔끔한 코드가 훌륭한 목표임을 말해주는 수많은 이유가 있다.

  • 예측 가능한 개발 방법이다. 끊임없이 발생할 버그에 대해 걱정하지 않고, 일이 언제 마무리될지 알 수 있다.
  • 코드가 가르쳐주는 모든 교훈을 학습할 기회를 갖게 된다. 처음 생각나는 대로 후딱 완료해 버리면 두 번째 것, 더 나은 것에 대해 생각할 기회를 잃게 된다.
  • 개발한 소프트웨어는 사용자의 삶을 향상시켜 준다.
  • 동료들이 당신을 존경할 수 있게 해주며, 나 역시 동료들을 존경할 수 있게 된다.
  • 작성하는 동안 기분이 좋다.

 

테스트 주도 개발은 자동화된 테스트로 개발을 이끌어가는 방식이다. 테스트 주도 개발에서는 다음의 두 가지 단순한 규칙만을 따른다.

  • 어떤 코드건 작성하기 전에 실패하는 자동화된 테스를 작성하고, 오직 자동화된 테스트가 실패할 경우에만 새로운 코드를 작성한다.
  • 중복을 제거한다.

 

또한 위의 두 가지 규칙에 의해 프로그래밍 순서가 다음과 같이 결정된다.

  1. 빨강: 실패하는 작은 테스트를 작성한다. 처음에는 컴파일조차 되지 않을 수 있다.
  2. 초록: 빨리 테스트가 통과하게끔 만든다. 이를 위해 어떠한 죄악(함수가 무조건 특정 상수만을 반환하는 등)을 저질러도 좋다.
  3. 리팩토링: 일단 테스트를 통과하게만 하는 와중에 생겨난 모든 중복을 제거한다.

TDD는 프로그래밍하면서 나타나는 두려움을 관리하는 방법이다. 프로그래밍의 두려움에는 정말 어려운 문제라서 시작 단계인 지금은 어떻게 마무리될 지 알 수 없는 두려움 등이 있다. 다음의 예제들을 통해 완전히 테스트에 의해 주도되는 개발을 해보도록 하자.

 

1. 화폐 예제


1부에서는 완전히 테스트에 의해 주도되는 전형적 모델 코드를 개발할 것이다. 이 장의 목표는 테스트 주도 개발(TDD)의 리듬을 보도록 하는 것이다. 그 리듬은 다음과 같이 요약할 수 있다.

  1. 재빨리 테스트를 하나 추가한다.
  2. 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
  3. 코드를 조금 바꾼다.
  4. 모든 테스트를 실행하고, 전부 성공하는지 확인한다.
  5. 리팩토링을 통해 중복을 제거한다.

이러한 리듬을 통해 다음과 같은 사실들을 배울 수 있을 것이다.

  • 각각의 테스트가 기능의 작은 증가분을 어떻게 커버하는지
  • 새 테스트를 돌아가게 하기 위해 얼마나 작고 못생긴 변화가 가능한지
  • 얼마나 자주 테스트를 실행하는지
  • 얼마나 수 없이 작은 단계를 통해 리팩토링이 되어가는지

[ 1. 다중 통화를 지원하는 Money 객체 ]

다음과 같은 보고서가 있다고 하자.

종목 가격 합계
IBM 1000 25 25000
GE 400 100 40000
    총합 65000

 

위의 보고서에 다중 통화를 지원하고자 하는데, 그러기 위해서는 통화 단위를 추가해야 한다.

종목 가격 합계
IBM 1000 25USD 25000USD
GE 400 100CHF 40000CHF
    총합 65000USD

 

또한 환율도 명시해야 한다.

기준 변환 환율
CHF USE 1.5

ex) 5USD + 10CHF = 10USD 와 같은 결과가 나와야 한다.

 

다중 통화 보고서가 제대로 계산됨을 확신하기 위해서는 다음과 같은 테스트 코드들이 있어야 할 것이다.

  • 통화가 다른 두 금액을 더해서 주어진 환율에 맞게 변한 금액을 결과로 얻을 수 있어야 한다.
  • 어떤 금액(주가)을 어떤 수(주식의 수)에 곱한 금액을 결과로 얻을 수 있어야 한다.

첫 번째 항목은 복잡해 보이므로, 두 번째 항목 부터 다루어보도록 하자.

우리는 TDD로 개발하고자 하므로, 어떠한 객체가 필요할지를 고민하는 것이 아니라, 가장 먼저 테스트를 작성해야 한다.

가장 빨리 초록색 막대를 보기 위해 테스트 코드를 작성하면 다음과 같다. (물론 위의 코드는 public 변수와, int형을 사용하는 등의 문제가 있다. 하지만 TDD는 우선 작은 단계부터 시작할 뿐이고, 이런 문제들은 추후에 수정될 것이다.)

public void testMultiplication() {
    Dollar file = new Dollar(5);
    five.times(2);
    assertEquals(10, five.amount);
} 

 

위의 코드들은 다음과 같은 이유들로 컴파일 에러가 발생한다.

  • Dollar 클래스가 없음
  • 생성자가 없음
  • times(int) 메소드가 없음
  • amount 필드가 없음

우리는 위와 같은 컴파일 에러들을 순차적으로 해결하기 위해 코드를 작성하다 보면, 다음과 같은 Dollar 클래스를 얻게 된다.

public class Dollar {
    
    int amount;
    
    Dollar(int amount) {
    }
    
    void times(int multiplier) {
    }
}

 

이제 컴파일 에러를 해결하고 테스트를 실행하면 빨간 막대를 보게 된다. 그리고 이제 우리의 목표는 '다중 통화 구현'이 아닌 '나머지 테스트 통과시키기' 이다.

가장 단순히 테스트를 통과시키는 방법은 amount를 10으로 설정해주는 것이다.

int amount = 10;

 

이렇게 해주면 이제 우리는 TDD의 4번 과정까지 진행이 된 것이다.

  1. 재빨리 테스트를 하나 추가한다.
  2. 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
  3. 코드를 조금 바꾼다.
  4. 모든 테스트를 실행하고, 전부 성공하는지 확인한다.
  5. 리팩토링을 통해 중복을 제거한다.

 

그리고 우리는 이제 중복을 제거해야 한다. 현재는 중복이 안보일 수 있지만 10을 5 * 2로 바꾸면 중복이 보인다.(실제로도 풀어서 쓰는 것이 맞다.) 5와 2라는 데이터가 테스트 코드와 클래스에 중복이 되는 것이다. 이를 제거하기 위해서는 amount와 multiplier를 정해줘야 하는데, 생성자에서는 amount를 times에서는 multiplier를 파라미터로 받도록 하자. 중복제거를 통해 탄생하게 된 Dollar 클래스는 다음과 같다.

public class Dollar {

    int amount;

    Dollar(int amount) {
        this.amount = amount;
    }

    void times(int multiplier) {
        this.amount = this.amount * multiplier;
    }
}

이러면 이제 5와 2라는 데이터의 중복을 제거하고, 곱셈에 대한 테스트를 마무리하게 된다. 하지만 아직 할일이 끝난 것은 아니다. amount를 private로 만들고, 돈의 계산을 int로 하는 등의 문제는 여전히 남아 있다.

누군가는 위의 단계가 너무 작다고 느낄 수 있다. 하지만 TDD의 핵심은 이런 작은 단계를 밟는 것이 아니라, 이런 작은 단계를 밟는 능력을 갖추어야 한다는 것이다. 물론 항상 이런식으로 작업을 할 필요는 없겠지만, 일이 꼬이기 시작한다면 이런 능력이 필요하게 될 것이다.

 

[ 2. 타락한 객체 ]

일반적인 TDD 주기는 다음과 같다.

  1. 테스트를 작성한다. 마음속에 있는 오퍼레이션이 코드에 어떤 식으로 나타나길 원하는지 생각해보고, 원하는 인터페이스를 개발하라
  2. 실행 가능하도록 만든다. 다른 무엇보다도 중요한 것은 빨리 초록 막대를 보는 것이다. 깔끔하고 단순한 해법이 보인다면 그것을 입력하라. 깔끔하고 단순한 해법이 명백히 보이지만 개발하는데 시간이 필요하다면, 일단 적어 놓은 뒤에 초록 막대부터 보도록 하자.
  3. 올바르게 만든다. 이제 시스템이 작동하도록 직전에 저질렀던 죄악들을 수습하자. 중복을 제거하고 초록 막대로 되돌아가자.

TDD의 목적은 작동하는 깔끔한 코드를 얻는 것이다. 이는 때로 최고의 프로그래머들조차 도달하기 힘들고, 평범한 프로그래머들에게는 거의 불가능한 일이다. 그렇다면 우리는 나누어서 정복해야 한다. 우선 작동하는 코드를 만들고 나서 깔끔한 코드를 만드는 것이다.

다시 다중 통화 예제로 돌아오면, 테스트를 하나 통과했지만 문제가 있다. 그것은 Dollar에 대한 연산 이후에, 해당 Dollar의 값이 바뀌는 점이다. 예를 들어 다음과 같은 테스트는 실패한다.

public void testMultiplication() {
    Dollar five = new Dollar(5);

    five.times(2);
    assertEquals(10, five.amount);

    five.times(3);
    assertEquals(15, five.amount);
}

 

이 문제를 통과할 가장 간단한 방법으로 times에서 새로운 객체를 반환하도록 하는 것이 있다. 이러면 테스트 코드와 Dollar 클래스의 변경이 필요할 것이다. 물론 이러한 방법이 완벽하지 못할 수 있지만, 문제될 것은 없다. 테스트 코드를 먼저 수정하면 다음과 같다.

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

 

현재는 times에 반환값이 없기 때문에 컴파일조차 되지 않을 것이다. 우리는 컴파일 에러를 해결하기 위해 times에 우선 null을 반환하도록 할 수 있다.

Dollar times(int multiplier) {
    this.amount = this.amount * multiplier;
    return null;
}

 

이제 컴파일은 되지만 테스트는 실패할 것이다. 그리고 다음 단계로 실제 Dollar 객체를 생성해서 반환하도록 하자.

Dollar times(int multiplier) {
    return new Dollar(amount * multiplier);
}

 

1장에서는 가짜 구현으로 시작해서 실제 구현을 만들었지만, 이번에는 올바른 구현이라고 생각한 내용을 입력한 후 테스트하였다. 이는 최대한 빨리 초록색을 보기 위한 두 가지 전략이다.

  • 가짜로 구현하기: 상수를 반환하게 만들고, 진짜 코드를 얻을 대 까지 단계적으로 상수를 변수로 바꾸어 간다.
  • 명백한 구현 사용하기: 실제 구현을 입력한다.

실무에서 TDD를 사용할 때 모든 일이 자연스럽게 진행되고 명확할 때에는 명백한 구현을 계속 더해나가면 된다. 물론 명백한 구현을 하면서도 확신을 위해 테스트를 한 번씩 실행해야 한다. 그러다 빨간 막대가 보이면 가짜로 구현하기를 사용하여 올바른 코드로 리팩토링하면 된다.

 

[ 3. 모두를 위한 평등 ]

앞서 Dollar처럼 객체를 값처럼 쓰는 패턴을 VO(Value Object) 패턴이라고 한다. VO의 제약사항 중 하나는 객체의 인스턴스 변수가 생성자를 통해 설정된 후에 변하지 않아야 한다는 것이다. 만약 누군가가 새로운 값을 지니는 Dollar 객체를 원한다면 새로 생성해주어야 한다. 또한 그럼에 따라 equals()와 hashCode 함수 역시 구현해주어야 한다. 왜냐하면 5달러는 다른 5달러와 동등해야 하기 때문이다. 그렇기 때문에 우리의 할 일에 2가지 함수의 구현이 추가되었다.

객체의 동치성에 대한 테스트 코드를 작성하면 다음과 같다.

public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5)));
}

 

이를 실행하면 빨간 막대가 보일 것이다.

우선 가짜로 구현하기 방법을 통해 equals가 무조건 true를 반환하도록 구현해두자.

public boolean equals(Object object) {
    return true;
}

 

그리고 이를 마무리하기 위해 세 번째 기법인 삼각측량 기법을 사용해보도록 하자. 삼각측량을 이용하려면 예제가 2개 이상 있어야만 코드를 일반화할 수 있다. 테스트 코드와 모델 코드 사이에 중복이 생기겠지만, 잠시 무시하고, 나중에 일반화하도록 하자. 예시를 추가하면 다음과 같다.

public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5)));
    assertFalse(new Dollar(5).equals(new Dollar(6)));
}

 

그리고 동치성을 일반화하여 equals를 다음과 같이 작성할 수 있다.

public boolean equals(Object object) {
    return amount == ((Dollar)object).amount;
}

 

이러면 이제 우리는 삼각측량 기법으로 equals를 재정의하는 일을 마무리 한 것이다. 앞서 times를 일반화할 때에도 삼각측량을 이용할 수 있었다. 5 * 2와 5 * 3이라는 테스트를 모두 가지고 있었다면, 상수를 반환하는 것 만으로는 테스트를 통과할 수 없었을 것이다.

이러한 삼각측량은 조금 이상한 면이 있기 때문에, 어떻게 리팩토링해야 하는지 감이 오지 않을때에만 이용하면 좋다. 코드와 테스트 사이의 중복을 제거하고, 일반적인 해법을 구할 방법이 보인다면 바로 그 방법대로 구현하는 것이 현명할 것이다. 하지만 설계를 어떻게 할 지 떠오르지 않을 때면, 삼각측량 기법은 조금 다른 방향에서 생각해볼 기회를 제공해 줄 것이다.

동일성 문제 자체는 해결이 되었지만, 해당 객체가 null이거나, 다른 객체와 비교한다면 문제가 발생할 수 있다. 이러한 부분은 당장 필요하지는 않으므로 할일 목록에 추가해두도록 하자.

 

[ 4. 프라이버시 ]

동치성 문제를 해결했으므로 테스트가 더 많은 이야기를 하도록 해보자.

개념적으로 Dollar.times() 연산은 곱해진 값을 지니는 Dollar를 반환해야 하지만, 기존에 작성했던 테스트 코드가 그것을 말하지는 않는다.

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

 

그렇기 때문에 기존의 테스트 코드를 객체를 비교하도록 재작성할 수 있다. 그리고 코드까지 정리하면 다음과 같은 테스트 코드를 얻게 된다.

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

이러한 테스트 코드는 우리의 의도를 더욱 명확하게 이야기해준다. 또한 이제 외부에서 amount를 접근하는 일이 없으므로, private으로 변경할 수 있다. 그리고 할 일은 또 한가지 줄었다.

하지만 위험한 상황이 만들어지기는 하였다. 그것은 동치성 테스트가 동치성이 올바르게 동작함을 검증하지 못한다면, 곱하기 테스트 역시 실패하게 된다는 것이다. 이것은 TDD를 하면서 적극적으로 관리해야 할 위험 요소이다.

물론 우리의 추론이 맞지 않아서 결함이 발생할 수 있다. 그럴 경우에는 어떻게 테스트를 작성했어야 하는가에 대해 교훈을 얻고 앞으로 나아가면 된다.

 

[ 5. 솔직히 말하자면 ]

이제 할 일들 중에서 서로 다른 두 통화를 더하는 일에 대해 살펴보도록 하자.

5USD + 10CHF = 10USD

떠오르는 가장 작은 단계는 Dollar를 표현하는 객체일 것이다. 그렇기에 먼저 Dollar 테스트를 복사한 후 수정해보자.

public void testFrancMultiplication() {
    Franc five = new Franc(5);
    assertEquals(new Franc(10), five.times(2));
    assertEquals(new Franc(15), five.times(3));
}

이 전의 작업 내용 때문에 지금 작업이 상당히 쉬워졌다. 현재는 컴파일 오류가 발생하고 있는데, 이를 해결하기 위한 가장 직관적인 방법은 Dollar 클래스를 복사하여 Franc 클래스를 만드는 것이다. 1~4 단계를 진행할 때에는 속도보다 설계가 더 중요한 선택지이다. 속도를 위해서는 설계의 교리들을 어길 수 있다.

Franc 클래스를 만들었다면 중복이 엄청 많기 때문에 다음 테스트를 작성하기 전에 이를 제거해야 한다. 우선 equals를 일반화하는 작업Franc 클래스를 만들었다면 중복이 엄청 많기 때문에 다음 테스트를 작성하기 전에 이를 제거해야 한다. 우선 equals를 일반화하는 작업부터 진행하고자 한다.

 

[ 6. 돌아온 '모두를 위한 평등' ]

중복을 제거하기 위해 떠오르는 가장 좋은 방법은 Money라는 공통 상위 클래스를 작성하는 것이다. 그리고 Money에 공통의 equals() 코드를 갖게 하면 중복을 해결할 수 있을 것이다. 우리는 Money 클래스를 만들고, amount를 Dollar와 Franc에서 Money로 옮길 수 있다. 그리고 접근 제어를 위해 Money의 amount를 protected로 바꿀 수 있다.

그리고 이제 Dollar와 Franc의 equals()를 손봐야한다. 먼저 Dollar의 equals를 Money 객체끼리 비교하도록 수정할 수 있을 것이고, 이 역시 Money 클래스로 옮길 수 있다. 이렇게 작성된 Money 클래스는 다음과 같을 것이다.

public class Money {

    protected int amount;

    Money(int amount) {
        this.amount = amount;
    }

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

 

이후에 Franc 클래스에서 amount 변수와 equals 함수를 제거하면 중복이 제거된다. 하지만 우리는 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)));
}

 

하지만 위의 테스트 코드는 2줄이나 중복되었다. 이에 대해서도 추후에 해결이 필요할 것이다.

이제 Franc에서 중복되는 코드들을 제거하면 중복 제거가 다 되었다. 그리고 테스트를 돌리면 잘 돌아갈 것이다.

하지만 Dollar와 Franc을 비교하면 어떻게 될까? 우리는 이 문제에 대해서도 해결해야 한다.

 

[ 7. 사과와 오렌지 ]

달러와 프랑은 다른 화폐이지만 테스트 코드를 작성하여 실행하면 동일하다고 나온다.

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

 

이를 해결하기 위한 가장 간단한 방법은 두 객체의 클래스를 비교하는 것이다. 그러면 두 클래스가 서로 동일할 때만 두 Money가 같은 것이다. 이를 위해 Money의 equals() 함수를 수정해보도록 하자.

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

위의 코드도 깔끔하지는 않지만, 초록 막대를 보기 위해 나중에 수정하도록 하자.

 

[ 8. 객체 만들기 ]

이제는 그 다음 중복인 times()를 처리해야 한다. times가 Money를 반환하도록 하면 더 비슷한 코드가 된다.

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

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

 

이렇게 진행하다 보니 Money의 두 하위 클래스를 제거해도 될 것 같다. 하지만 그렇게 큰 단계를 밟아버리면 TDD를 효과적으로 보여줄 수 없으므로 조금 더 작은 단계를 진행해보도록 하자.

하위 클래스에 대한 직접적인 참조가 적어진다면 하위 클래스 제거가 용이해질 것이다. 직접 참조를 제거하기 위해 Money에 Dollar를 반환하는 팩토리 메소드를 도입해보자.

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

 

테스트 코드에서도 직접 참조하는 부분을 모두 제거하여 다음과 같이 수정할 수 있다.

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

 

하지만 아직 times가 Money에 존재하지 않으므로 컴파일 에러가 발생할 것이다. 아직은 times를 구현할 준비가 되지 않았기 때문에, Money를 추상 클래스로 변경하고 추상 메소드 times()를 선언해주자.

public abstract class Money {
    abstract Money times(int multiplier);
}

 

그리고 Dollar를 반환하는 팩토리 메소드의 반환 클래스를 Money로 수정해주자.

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

 

그리고 Dollar를 직접 참조하는 다른 테스트 코드에서 역시 팩토리 메소드를 사용하도록 변경하면 코드가 한층 괜찮아졌다.(어떤 클라이언트 코드도 Dollar라는 하위 클래스의 존재를 모른다.)

그리고 testFrancMultipication 역시 팩토리 메소드를 작성하여 변경하려고 보니, Dollar에 의해 모든 테스트가 검사되어 지워도 될 것 같다. 하지만 일단 남겨두도록 하고, testFrancMultipication를 수정하도록 하자.

static Money franc(int amount) {
    return new Franc(amount);
}

public void testFrancMultiplication() {
    Money five = Money.franc(5);
    assertEquals(Money.franc(10), five.times(2));
    assertEquals(Money.franc(15), five.times(3));
}

그리고 이제 times를 진짜 제거해보도록 하자.

 

[ 9. 우리가 사는 시간 ]

중복과 불필요한 하위 클래스를 제거하기 위해서는 통화 개념을 도입해야 할 것이다. 우리는 TDD로 개발중이므로 통화 개념을 어떻게 구현하는지가 아니라 통화 개념을 어떻게 테스트 하는지를 생각해야 한다.

더 많은 좋은 방법들이 있겠지만, 가장 먼저 떠오르는 String을 이용해 처리해보도록 하자. 통화 관련 테스트 코드를 작성하면 다음과 같다.

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

 

아직은 컴파일 에러가 발생할 것이므로 Money에 currency 추상 메소드를 추가해주자.

abstract String currency();

 

그러면 하위 클래스들에 이를 구현해야 할 것이므로, 각각에 이를 구현해주도록 하자.

String currency() {
    return "CHF";
}

String currency() {
    return "USD";
}

 

하지만 이러한 방법보다 통화를 인스턴스 변수에 저장하고, 메소드에서는 이를 반환하도록 하면 더 좋을 것 같다. 그러므로 currency라는 지역변수를 생성하고, 이를 생성자에서 저장하도록 구현하자.

public class Dollar {
    private String currency;

    Dollar(int amount) {
        this.amount = amount;
        this.currency = "USD";
    }
  
    String currency() {
        return currency;
    }
}

 

Franc도 이와 유사하게 작업을 하다 보면 코드가 중복됨을 확인할 수 있다. 위의 코드를 Money 클래스로 옮겨 중복을 제거할 수 있을 것 같다.

public class Money {
   protected String currency;

   String currency() {
        return currency;
   }
}

 

그러면 이제 currency를 설정해주는 코드가 필요한데, 이를 생성자로 옮기면 공통 로직을 만들 수 있다. 하지만 생성자는 추가로 수정이 필요해보인다.

public class Franc {

    Franc(int amount, String currency) {
        this.amount = amount;
        this.currency = "CHF";
    }
}

 

생성자를 만들면 팩토리 메소드와 times에 컴파일 에러가 발생하므로 수정이 필요하다.

Money Class
static Money franc(int amount) {
    return new Franc(amount, null);
}

Franc Class
Money times(int multiplier) {
    return new Franc(amount * multiplier, null);
}

 

또한 times를 수정하다보니 팩토리 메소드를 사용하지 않고 있는 것을 확인할 수 있다. 일반적으로는 이를 나중에 수정하는 것이 좋겠지만, 다음과 같은 간단한 작업은 지금 처리해도 괜찮을 것이다. 그러므로 times를 생성자가 아닌 팩토리 메소드를 호출하도록 수정하자. 그리고 그에 맞게 클래스 생성자도 수정해주도록 하자.

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

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

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

 

그리고 이제 Dollar 까지 수정을 하면 하위 클래스들의 생성자가 동일해졌으므로 이를 상위 클래스인 Money로 옮길 수 있다. 그리고 하위 클래스들에서는 super로 동일한 생성자를 호출하도록 변경하자.

이렇게 지금까지 구현된 각각의 클래스를 살펴보면 다음과 같을 것이다.

public abstract class Money {
    protected int amount;
    protected String currency;

    Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    
    static Money dollar(int amount) {
        return new Dollar(amount, "USD");
    }

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

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

public class Dollar extends Money {
    
    Dollar(int amount, String currency) {
        super(amount, currency);
    }

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

public class Franc extends Money {
    
    Franc (int amount, String currency) {
        super(amount, currency);
    }

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

이제 times()의 중복을 Money 클래스로 올리고, 하위 클래스를 제거하기 위한 단계로 많이 나아갔다.

이렇듯 작은 단계를 밟는 것은 우리가 반드시 이렇게 일해야 한다고 얘기하는 것이 아니다. 대신 복잡한 문제를 마주했을 때, 이렇게 작은 단계로 풀어 나갈 능력이 있어야 한다는 것이다.

 

[ 10. 흥미로운 시간 ]

이제는 진짜 times()의 중복을 제거하고 하위 클래스를 제거할 차례이다. 두 times() 구현이 거의 비슷하긴 하지만 완전히 동일하지는 않다.

Money times(int multiplier) {
    return Money.dollar(amount * multiplier);
}
    
Money times(int multiplier) {
    return Money.franc(amount * multiplier);
}

 

그래서 우선 작성했던 팩토리 메소드를 생성자로 되돌려보도록 하자.

Money times(int multiplier) {
    return new Dollar(amount * multiplier, "USD");
}
    
Money times(int multiplier) {
    return new Franc(amount * multiplier, "CHF");
}

 

그 중에서 생성자를 위한 currency 변수는 이미 존재하므로, 인스턴수 변수를 사용하도록 변경하자.

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

 

이렇게 코드를 작성해보니 화폐 정보는 currency라는 인스턴스 변수에 담겨있기 때문에 굳이 하위 클래스를 반환할 필요가 없어보인다. 그러므로 Money 객체를 반환하도록 수정해주자.

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

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

 

이렇게 수정하면 Money가 추상클래스이기 때문에 객체를 생성할 수 없다는 컴파일 에러가 발생한다. Money에 abstract를 제거하고, times를 null을 반환하도록 임시로 구현해두자.

public class Money {

    Money times(int amount) {
        return null;
    }
}

 

그리고 테스트 코드를 돌려보면 times와 equals 테스트 코드에서 두 클래스가 일치하지 않는다고 나오고 실패한다. 이는 equals 함수 때문일 것인데, 현재는 클래스를 비교하고 있으므로 이를 currency 비교하도록 수정이 필요하다.

즉, 지금 우리에게 필요한 것은 모델 코드를 수정하려는 것이다. 하지만 우리는 현재 times가 실패를 보여주고 있는 상황이고, 우리는 TDD로 개발을 하고 있기 때문에 다음과 같은 작업 순서로 진행을 해야 한다.

  1. times 테스트가 성공하도록 times 코드를 되돌리기
  2. equals를 위해 테스트를 고치고 구현 코드를 고친다
  3. times의 중복을 제거한다

그렇지 않으면 우리는 times 중복을 제거하면서, model 코드를 수정하면서, 서로 다른 두 클래스를 비교하는 테스트 코드를 작성하는 일을 동시에 해야한다.

그러므로 우선 Franc 클래스의 times를 복구시키자.

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() 함수를 수정해주도록 하자.

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

 

그러면 이제 times를 수정해도 테스트가 여전히 통과할 것이다.

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

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

 

그러면 위의 코드는 중복이므로 상위 클래스로 끌어올릴 수 있다.

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

 

[ 11. 모든 악의 근원 ]

이제 하위 클래스들은 생성자밖에 존재하지 않으므로 제거가 가능할 것이다. 하지만 아직 Money.franc과 Money.dollar에서 이를 사용하고 있으므로 변경이 필요하다.

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

static Money franc(int amount) {
    return new Money(amount, "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)));
}

그리고 이 중에서 세 번째 네 번째는 각각 첫 번재 두 번재와 중복이므로 제거해주도록 하자. 또 다른 동치성 테스트인 testDiffrenctClassEquality를 살펴보니 충분한 테스트를 하고 있으므로, 이 역시 제거해도 될 것 같다. 그리고 마지막으로 하위 클래스를 모두 제거하고 Money 클래스만 남겨두도록 하자.

 

[ 12. 드디어, 더하기 ]

이제 우리는 동일한 두 화폐와 동일하지 않은 두 화폐를 더하는 일만 남았다.

5USD + 5USD = 10USD

5USD + 10CHF = 10USD(환율이 2:1일 경우)

우선 동일한 두 화폐를 더하는 기능부터 시작해보자. 우선 두 화폐를 더하는 기능에 대한 테스트 코드를 작성하자.

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

 

아직은 plus 함수가 없어 컴파일 에러가 날 것이므로, plus 함수를 Money 클래스에 추가해주도록 하자.

Money plus(Money addend) {
    return new Money(amount + addend.amount, currency);
}

Money.dollar(10)을 반환하는 식으로 가짜 구현을 할 수도 있다. 하지만 현재는 구현이 명백한 상황이므로 바로 구현하도록 하자. 어떻게 구현할 지 명백하지 않다면 가짜 구현을 하고 리팩토링 해도 좋다.

그리고 다중 통화를 더할 때에는 여러 환율을 표현할 수 있어야 한다. 이를 해결하기 위한 방법은 더한 두 결과를 Expression으로 두고 또 다른 객체를 통해 환율을 적용하는 것이다. 이를 테스트 코드로 작성하면 다음과 같을 것이다.

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

 

아직은 컴파일 에러가 발생할 것이므로, 컴파일을 위해 Expression 인터페이스와 Bank 클래스를 추가해주어야 한다. 그렇기에 우선 Expression 인터페이스를 만들고 plus 함수가 이를 반환하도록 변경하자.

Expression plus(Money addend) {
    return new Money(amount + addend.amount, currency);
}

 

그리고 이를 해결하려면 Money가 expression을 구현해야 한다. 그래서 Money 클래스에 Expression 인터페이스를 구현해주도록 하자.

public class Money implements Expression {
    ...
}

 

그리고 추가로 Bank라는 클래스와 reduce() 함수를 만들어 주도록 하자.

public class Bank {
    Money reduce(Expression source, String to) {
        return null;
    }
}

 

이제 컴파일은 해결되었지만 아직 빨간 막대가 보인다. reduce는 어떻게 구현할지 명백하지 않으므로 가짜 구현을 해주도록 하자.

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

 

[ 13. 진짜로 만들기 ]

모든 중복을 제거하기 전에는 5USD + 5USD의 테스트를 완료할 수 없다. 현재 코드 중복은 없지만 데이터 중복은 있다. 그것은 테스트 코드의 5를 더하는 것과 reduce의 10을 반환하는 것이다. 이를 해결하기 위해 reduce 함수를 수정해야 한다. 하지만 아직 잘 감이 오지 않으므로, 테스트 코드부터 손보도록 하자.

우선 Expression 으로 반환되는 결과는 덧셈에 대한 표현이고, 이를 위해서는 덧셈의 구현체가 필요하다. 그리고 그 구현체는 더할 두 대상을 저장하고 있어야 할 것이다.

두 화폐의 덧셈을 위한 테스트 코드를 작성하면 다음과 같다.

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

 

위 코드는 외부 행위가 아닌 내부 구현에 너무 깊게 관여하고 있으므로, 추후에 수정이 필요할 것이다.

하지만 우선 컴파일 에러를 잡기 위해 Sum 클래스를 생성해주도록 하자.

public class Sum {
    Money augend;
    Money addend;
}

 

그리고 현재 Money의 plus는 Sum이 아닌 Money를 반환하고 있으므로 ClassCastException이 발생한다. 그렇기에 Money 클래스의 plus 함수가 Sum을 반환하도록 변경해주자.

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

 

이러다 보니 Sum의 생성자가 필요한데, 추가해주도록 하자. 또한 Sum은 Expression의 일종이어야 하므로 Expression을 구현하도록 해주자.

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

    Sum(Money augend, Money addend) {
    }
}

 

이제 컴파일은 해결 되었지만 테스트는 실패한다. 왜냐하면 생성자에서 필드를 설정하지 않았기 때문이다. 그렇기에 Sum의 생성자를 수정해주도록 하자.

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

 

이제 reduce는 Sum을 전달받게 된다. 만약 Sum이 가지고 있는 통화가 동일하며, reduce를 통해 얻고자 하는 통화 역시 동일하다면, 결과는 Sum 내에 있는 Money들의 amount를 합친 Money 객체여야 할 것이다. 그렇기에 reduce에 대한 테스트 코드를 작성하도록 하자.

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

 

위의 테스트 코드는 실패한다. 그렇기에 우리는 이를 통과하기 위한 Bank의 reduce를 구현해주어야 한다.

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

 

하지만 위의 코드는 다음의 두가지 이유로 깔끔하지 못하다.

  • 이 코드는 Sum으로 캐스팅하므로, 모든 Expression에 대해 동작하지 못한다.
  • public 필드와 그 필드들에 대해 2단계에 걸쳐 접근한다.

위의 문제를 해결하는 것은 간단하다. 우선 2단 참조를 줄이기 위해 Sum에서 reduce를 구현하여 호출하도록 하는 것이다. 그렇기에 다음과 같이 수정해주도록 하자.

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

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

 

그리고 이를 통과하려면 Bank의 reduce를 다음과 같이 수정해야 한다.

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

 

이렇게 코드를 작성하면 다행히 테스트는 통과한다. 그러므로 이제 이를 리팩토링 해보도록 하자.

클래스를 명시적으로 검사하는 코드가 있을 때에는 항상 다형성을 사용하도록 하는 것이 좋다. Sum은 reduce(String)를 구현하므로, Money도 그것을 구현하도록 만든다면 reduce()를 Expression 인터페이스에도 추가할 수 있다.

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

Expression Interface
Money reduce(String to);

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

 

Expression과 Bank에 이름은 동일하지만 매개변수만 다른 함수가 있다는 것은 불만족스럽다. 파이썬은 이를 매끄럽게 해결할 수 있지만, Java에서는 만족스러운 해법이 없는 것 같다.

지금까지 나온 클래스와 인터페이스를 정리하고 나면 다음과 같다.

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

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

public class Money implements Expression {
    protected int amount;
    protected String currency;

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

    private String currency() {
        return currency;
    }

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

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

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

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

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

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

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

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

 

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

 

컴파일을 빠르게 해결하기 위해서는 Bank 클래스에 addRate 함수를 추가해야 한다.

public void addRate(String from, String to, int rate) {
}

 

그리고 가장 빨리 초록 막대를 보기 위한 방법은 환율 계산 정보를 Money 클래스의 reduce에 추가하는 것이다.

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

 

이렇게 코드를 작성하면 초록 막대를 볼 수는 있다. 하지만 Money가 환율에 대해 알게 되었다. 이러한 구조는 적합하지 않으며, 환율에 대한 처리는 Bank에서 하는 것이 옳다.

그렇기에 이를 호출하는 부분을 Bank 클래스에 작성하면 다음과 같다.

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

 

이러면 컴파일 에러가 발생할 것이다. 이를 해결하기 위해 Expression과 Bank 클래스 그리고 Sum 클래스의 reduce에 첫 번째 파라미터를 추가해야 한다.

Expression Interface
Money reduce(Bank bank, String to);

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

Sum Class
@Override
public Money reduce(Bank bank, String to) {
    return null;
}

 

그러면 이제 Money 클래스에서 Bank를 파라미터로 받으므로, 환율의 계산을 Bank에게 맞길 수 있다. 그렇기에 다음과 같은 환율 계산 메소드를 Bank 클래스에 추가해주자.

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

 

그러면 이제 현재 Money가 환율에 의존하는 코드는 필요가 없으므로, Money 클래스에서 관련 코드를 수정해주도록 하자.

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

 

이제 실행하면 초록 막대가 나오겠지만, 아직 Bank가 환율에 대해 직접적으로 알고 있다는 것은 좋지 않다. 이를 관리하기 위한 별도의 클래스를 생성하고자 한다.

public class Pair {

    private String from;
    private String to;

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

 

그리고 Pair를 Key로 쓰고, 그에 대한 환율을 꺼내야 하므로 equals와 hashCode를 구현해야 한다. (지금은 리팩토링하는 중에 코드를 작성하는 것이므로, 테스트를 작성하지는 않는다.)

@Override
public boolean equals(Object object) {
    Pair pair = (Pair) object;
    return from.equals(pair.from) && to.equals(pair.to);
}

@Override
public int hashCode() {
    return 0;
}

여기서 0을 반환하는 해시코드는 최악이다. 해시코드가 0이라면 선형 탐색처럼 해시테이블에서 탐색을 할 테지만, 가짜로 구현해두고 넘어가도록 하자.

그리고 은행에서는 환율을 저장하기 위핸 객체가 필요하다. 그렇기에 환율 저장을 위한 HashTable 인스턴스 변수를 추가하고, addRate와 rate 함수를 마무리하도록 하자.

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

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

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

 

하지만 테스트 코드를 실행하면 실패한다. 그 이유는 USD에서 USD로의 환율이 1이 되어야 하기 때문이다. 이에 대한 테스트 코드를 다음과 같이 추가하자.

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

 

그리고 Bank 클래스의 rate 함수에는 다음과 같이 동일할 경우 1로 반환하는 코드를 넣어주자.

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

 

[ 15. 서로 다른 통화 더하기 ]

이제 서로 다른 두 통화를 더하는 일만 남았다. 이제 이 작업의 시초인 $5 + 10CHF = $10에 대한 테스트를 작성해보자.

@Test
public void testMixedAddition() {
    Expression fiveBucks = Money.dollar(5);
    Expression tenFrancs  = Money.franc(5);
    Bank bank = new Bank();
    bank.addRate("CHF", "USD", 2);
    Money result = bank.reduce(fiveBucks.plus(tenFrancs), "USD");
        
    assertEquals(Money.dollar(10), result);
}

 

위와 같이 테스트를 작성하면 컴파일 에러가 발생한다. 우선 컴파일 에러를 잡기 위해 반환값을 Expression에서 Money로 변경하자.

@Test
public void testMixedAddition() {
    Money fiveBucks = Money.dollar(5);
    Money tenFrancs  = Money.franc(5);
    Bank bank = new Bank();
    bank.addRate("CHF", "USD", 2);
    Money result = bank.reduce(fiveBucks.plus(tenFrancs), "USD");
        
    assertEquals(Money.dollar(10), result);
}

 

그리고 테스트를 실행하면 10USD 대신 15USD가 나오면서 테스트가 실패한다. 테스트가 실패하는 이유는 Sum.reduce()가 환율을 처리하지 않기 때문으로 분석된다.

그렇기에 Sum의 reduce 함수에서도 환율을 계산하도록 처리하면 다음과 같이 수정해야 한다.

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

 

이렇게 수정하면 테스트가 통과하고, 이제 리팩토링을 해야한다. 우선 Sum 클래스와 같이 Money라는 구체 클래스로 구현된 부분은 모두 Expression으로 바꿔줄 수 있다.

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

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

 

그 외에도 times나 times의 Money를 Expression으로 대체가능하므로 변경해주도록 하자.

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

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

 

그러면 이제 테스트 케이스의 Money 부분을 Expression으로 바꾸도록 하자.

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

 

그러면 Expression에 plus가 존재하지 않는다고 컴파일 에러가 발생한다. 그러므로 Expression에 plus를 추가해주고, 그러면 Money와 Sum에도 이를 구현해야 할 것이다. Money에는 plus가 이미 있으므로, public으로만 설정해주면 된다.

Expression Interface
Expression plus(Expression addend);

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

Sum Class
public Expression plus(Expression addend) {
    return null;
}

Sum 에는 우선 가짜로 구현해도록 하자.

 

[ 16. 드디어, 추상화 ]

Expression의 plus를 마무리하려면 가짜 구현된 Sum의 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);
}

위의 예제에서 fivebucks.plus()를 이용해서도 Sum을 만들 수 있지만, 의도적으로 Sum을 보여주기 위해 위와 같이 작성하였다.

그리고 다음과 같이 Sum 클래스의 plus를 채우면 테스트는 성공하게 된다.

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

 

그리고 이제 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);
}

 

그러면 Sum에 times가 존재하지 않아 컴파일 에러가 발생한다. Sum에 다음과 같은 times 함수를 정의할 수 있다.

Expression times(int multiplier) {
    return new Sum(augend.times(multiplier), addend.times(multiplier));
}

 

그러면 Expression 클래스의 변수인 augend와 addend에 times가 존재하지 않아 컴파일 에러가 또 발생하므로, Expression에도 times를 추가해준다.

Expression times(int multiplier);

 

그러면 Sum과 Money를 public으로 변경해주어야 한다.

Sum Class
public Expression times(int multiplier) {
    return new Sum(augend.times(multiplier), addend.times(multiplier));
}

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

그러면 테스트는 통과한다.

 

[ 17. Money 회고 ]

TDD의 주기는 다음과 같다.

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

또한 TDD의 부산물로 자연스레 생기는 테스트들은 시스템의 수명이 다할 때까지 함께 유지되어야 할 만큼 확실히 유용하다. 하지만 이 테스트들은 다음 종류의 테스트들을 대체할 수는 없다.

  • 성능 테스트
  • 스트레스 테스트
  • 사용성 테스트

그 외에 TDD를 하면 다음과 같은 사실들을 새롭게 받아들이게 된다.

  • 테스트를 확실히 돌아가게 만드는 세 가지 접근법
    • 가짜로 구현하기
    • 삼각측량법
    • 명백하게 구현하기
  • 설계를 주도하기 위한 방법으로 테스트 코드와 실제 코드의 중복을 제거하기
  • 테스트 사이의 간격을 조절하는 능력

 

 

관련 포스팅

  1. Test-Driven Development: By Example(테스트 주도 개발) 내용 정리 - 예시 (1)
  2. Test-Driven Development: By Example(테스트 주도 개발) 내용 정리 - 방법 (2)

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함