jujuwon
시크릿주주
jujuwon
전체 방문자
11,697
오늘
0
어제
36
  • 분류 전체보기 (91)
    • 🔠 프로그래밍언어 (34)
      • ☕️ Java (19)
      • 🐠 Python (15)
    • 🔙 Backend (15)
      • 🌿 Springboot (12)
      • 🐳 컨테이너 (0)
      • ☁️ AWS (3)
    • 💼 CS (10)
      • 📶 Network (10)
    • 🕹 알고리즘 (4)
      • 📑 스터디 (2)
      • 💁🏻‍♂️ 백준 (2)
    • 📚 Book (8)
      • 🔎 오브젝트 (4)
      • 🧪 TDD (2)
      • 📜 논문 (2)
    • 🔐 보안 (7)
      • 👾 Pwnable (7)
    • 📝 회고 (4)
    • 🧩 etc. (9)
      • ⚠️ issue (2)
      • 💡 꿀팁 (6)
      • ✏️ 끄적 (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

  • Python :: 5 - 여러 번 반복하는 일을 하자
    2021.05.31
    Python :: 5 - 여러 번 반복하는 일을 하자
  • Gradle :: Lombok 추가 시 이슈
    2022.06.26
    Gradle :: Lombok 추가 시 이슈
  • Spring Boot :: 처음 시작하는 Gradle 프로⋯
    2022.06.25
    Spring Boot :: 처음 시작하는 Gradle 프로⋯
  • 우리팀의 코딩 컨벤션 정하기
    2023.02.10
    우리팀의 코딩 컨벤션 정하기
  • GDB :: gdb 사용법 및 옵션 정리
    2021.04.09
    GDB :: gdb 사용법 및 옵션 정리

최근 글

  • RDS :: Too many connections 오류 해⋯
    2023.02.21
    RDS :: Too many connections 오류 해⋯
  • RDS :: Incorrect string value: '⋯
    2023.02.20
    RDS :: Incorrect string value: '⋯
  • 알고리즘 스터디 :: 입출력 (Java, Python)
    2023.02.17
    알고리즘 스터디 :: 입출력 (Java, Python)
  • 백준 :: 10951번 A+B-4 (Java, Python⋯
    2023.02.16
    백준 :: 10951번 A+B-4 (Java, Python⋯
  • 알고리즘 스터디 :: 알고리즘이란 ?
    2023.02.16
    알고리즘 스터디 :: 알고리즘이란 ?
hELLO · Designed By 정상우.
jujuwon

시크릿주주

TDD :: 화폐 예제 (1/2)
📚 Book/🧪 TDD

TDD :: 화폐 예제 (1/2)

2023. 2. 1. 00:17
728x90
반응형

 

리듬을 보자.


이 책의 1부에서는 테스트에 의해 주도되는 전형적인 모델 코드를 개발한다.

테스트 주도 개발의 리듬을 보자.

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

 

다중 통화를 지원하는 Money 객체


이런 보고서가 있다고 하자.

 

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

 

다중 통화를 지원하는 보고서를 만들려면 통화 단위를 추가해야 한다.

 

종목 주 가격 합계
IBM 1000 25USD 25000USD
Novartis 400 150CHF 60000CHF
    합계 65000USD

 

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

 

기준 변환 환율
CHF USD 1.5

 

새로운 보고서를 생성하려면 필요한 기능은 무엇일까 ?

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

그렇다면 이제 무엇을 해야 할지, 할일 목록을 작성해보자.

🙋‍♂️ : 이제 어떤 객체를 만들어야 하는지 생각하면 되나요 ?

💁🏻‍♂️ : 땡 ❌ 테스트를 먼저 만들어야 합니다 ~

우리가 개발해야 할 목록 ⬇️

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10

또 다른 테스트가 생각나면 계속 추가해나가면서

목록에 있는 작업을 시작하면 이렇게 굵은 글씨체 로 나타내보고,

작업을 끝낸 항목은

이렇게 줄을 그어보자

.

할일 중에서 환율에 맞게 결과로 얻는 건 좀 어려워보인다 ..

두번째 곱셈은 좀 쉬워보이니까 이거 먼저 하자 !

테스트를 작성할 때는 오퍼레이션의 완벽한 인터페이스를 먼저 상상해보자.

아래는 간단한 곱셈의 예이다.

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

public 필드랑 금액을 계산하는데 정수형을 쓰고.. 등등은 일단 신경쓰지 말자.

작은 단계로 시작하는 거다 !

우리가 해야 할 일들을 적어보고 중간체크를 해보자.

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?

방금 작성한 테스트는 컴파일도 안 된다..

현재 네 개의 컴파일 에러가 있다.

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

한 번에 하나씩 정복하자. 일단 컴파일만 되게 하는거다.

1. 먼저 Dollar 클래스 만들기

class Dollar {}

2. 생성자 만들기

class Dollar {
    public Dollar(int amount) {}
}

3. times 메소드 만들기

class Dollar {
    public Dollar(int amount) {}

    void times(int multiplier) {}
}

4. amount 필드 추가

class Dollar {
    int amount;

    public Dollar(int amount) {}

    void times(int multiplier) {}
}

자 이제 컴파일이 된다. 테스트를 실행해보자 !

 

 

테스트가 실패했다 👏

우리는 지금 공포의 빨간 막대 를 보고 있다.

하지만 이제 우리의 문제는 “다중 통화 구현” 에서

“이 테스트를 통과시킨 후 나머지 테스트들도 통과시키기” 로 변형되었다.

자 그럼 TDD 의 리듬을 다시 기억해보자.

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

2번까진 됐으니 코드를 조금 수정해서 테스트를 성공시켜보자.

어떻게 하면 될까 ?

class Dollar {
    int amount = 10;

    public Dollar(int amount) {}

    void times(int multiplier) {}
}

테스트가 성공했다 ✌️

 

 

이게 무슨 말도 안 되는 장난이냐 하겠지만 .. 일단 TDD 의 리듬을 받아들이자 ㅎㅎ

일단 우리는 TDD 리듬의 4번까지를 수행했다.

이제 중복을 제거할 차례다.

🙋‍♂️ : 근데.. 어디가 중복이죠?

방금 테스트를 통과시키기 위해 이런 코드를 추가했었다.

class Dollar {
    int amount = 10;
}

여기서 10은 사실 우리가 이미 머릿속으로 곱셈을 하고나서

테스트 결과로 10이 나온다고 알고 있기 때문에 작성한 것이다.

즉, 코드를 조금 수정한다면

class Dollar {
    int amount = 5 * 2;
}

이렇게 5와 2가 두 곳에 중복해서 존재한다. 규칙대로 이 중복을 제거해보자.

객체의 초기화 단계에 있는 이 설정 코드를 times() 메소드로 옮겨보면 어떨까?

class Dollar {
    int amount = 10;

    void times(int multiplier) {
        amount = 5 * 2;
    }
}

테스트는 여전히 통과한다.

이 단계가 너무 작게 느껴질 수 있지만, TDD 의 핵심은 이런 작은 단계를 밟을 능력을 갖추어야 한다 는 것 !

그렇다면 이제 5는 어디서 얻을 수 있을까를 생각해보자.

이건 생성자에서 넘어오는 값이니 아래처럼 amount 변수에 저장하자.

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

그럼 그걸 times() 에서 사용할 수 있다.

class Dollar {
    void times(int multiplier) {
        amount = amount * 2;
    }
}

인자 multiplier 의 값이 테스트 코드에서 넘어오는 2 이므로, 상수를 이 인자로 대체할 수 있다.

class Dollar {
    void times(int multiplier) {
        amount *= multiplier;
    }
}
  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?

자 이제 첫번째 테스트에 완료 표시를 할 수 있게 됐다 !

 

타락한 객체


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

  1. 테스트를 작성한다. 원하는 인터페이스를 개발하자.
  2. 실행 가능하게 만든다. 일단 진짜 돌아가게만 (나중에 사과하면 된다)
  3. 올바르게 만든다. 잘못했던 거 싹싹 빌기.. 그리고 중복 제거하기

우리의 목적은 작동하는 깔끔한 코드 를 얻는 것 !

사실 말이 쉽지 굉장히 어려운 일이다.

그렇다면 일단 분할 정복하자. 먼저 “작동하는” 에 해당하는 부분을 먼저 해결하고

“깔끔한 코드” 를 나중에 해결하자.

우리의 할일 목록을 다시 살펴보자.

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?

테스트를 하나 통과했지만 이상한 점이 있다.

Dollar 에 대한 연산을 수행하고 해당 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);
}

일단 이 새 테스트는 컴파일조차 되지 않는다.

컴파일되도록 수정하자.

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

이제 테스트가 컴파일된다. 하지만 실행되지는 않는다.

기억하자. 한 걸음씩 !

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

이제 두 번째 테스트를 구현 완료했다.

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?

첫 번째 테스트 때는 가짜 구현으로 시작해서 점차 실제 구현을 만들어갔지만

이번에는 올바른 구현이라고 생각한 내용을 먼저 입력했다.

어떤 방식으로든 빨리 초록 막대를 볼 수 있도록 조치하면 된다.

  • 가짜로 구현하기 : 상수를 반환하게 만들고 단계적으로 실제 구현으로 만들기
  • 명백한 구현 사용하기 : 실제 구현 입력

어떻게 구현해야 할지 알 때는 명백한 구현을 해나가고,

예상치 못한 빨간 막대를 만나게 되면 뒤로 한발짝 물러나서 가짜로 구현하기 !

그러고 다시 올바른 코드로 리팩토링 하는 것이다.

우리가 한 것을 정리하면,

  • 설계상의 결함(Dollar 부작용)을, 그 결함으로 인해 실패하는 테스트로 변환
  • 스텁 구현으로 빠르게 컴파일을 통과하도록 만듬
  • 올바른 코드를 입력해 테스트 통과

 

모두를 위한 평등


어떤 정수에 1을 더했을 때 우리는 원래 정수가 변할 거라고 예상하기보다는

원래 정수에 1이 더해진 새로운 값을 갖게 될 거라고 예상한다.

Dollar 객체같이 객체를 값처럼 쓰는 이것을 value object pattern(값 객체 패턴) 이라고 한다.

값 객체 제약사항 중 하나는 객체 인스턴스 변수가 생성자를 통해 일단 설정된 후에는

결코 변하지 않는다는 것 !

값 객체를 사용하고 $5를 설정하면 그 값이 영원히 $5임을 보장받을 수 있다.

값 객체가 암시하는 것 중 하나는,

이전 테스트와 같이 모든 연산은 새 객체를 반환해야 한다는 것.

또 다른 암시는 값 객체는 equals() 를 구현해야 한다는 것이다.

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()

또 Dollar 를 해시 테이블의 키로 쓸 생각이라면 equals() 를 구현할 때

hashCode() 를 같이 구현해야 한다.

// TEST
public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5)));
}
// Dollar
public boolean equals(Object obj) {
    return true;
}

이제 여기서 삼각측량 계산법을 이용해보자.

삼각측량이란 ?
라디오 신호를 두 수신국이 감지하고 있을 때,
수신국 사이 거리와 신호의 방향을 알고 있다면
이 정보들만으로 신호의 거리와 방위를 알 수 있는 계산법 !

삼각측량을 하기 위해 두번째 예제가 필요하다.

$5 ≠ $6 을 추가해보자 !

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

이제 equality 를 일반화하자.

public boolean equals(Object obj) {
    Dollar dollar = (Dollar) obj;
    return amount == dollar.amount;
}
  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()

삼각측량은 문제를 조금 다른 방향에서 생각해볼 기회를 제공한다.

이제 동일성 문제는 일시적으로 해결됐다.

하지만 null 이나 다른 객체들과 비교한다면 어떻게 될까?

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object

일단 추가만 해두고 넘어가자.

일단 equals 비교를 구현했으니 인스턴스 변수 amount 를 private 으로 만들 수 있게 됐다.

 

프라이버시


이제 equals 문제를 정의했으니까 이를 이용해서 테스트가 더 많은 이야기를 해보자.

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
public void testMultiplication() {
    Dollar five = new Dollar(5);
    assertEquals(new Dollar(10), five.times(2));
    assertEquals(new Dollar(15), five.times(3));
}

테스트를 고치고 나니 이제 Dollar 의 amount 인스턴스 변수를 사용하는 코드는

Dollar 자신 밖에 없다.

이제 변수를 private 으로 변경할 수 있다.

왜 private 으로 변경 가능한지 이해가 안 된다면 여기를 보자.

https://blog.jujuwon.dev/94

class Dollar {
    private int amount;
    ...
}
  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object

하지만 취약한 부분이 있다.

만약 동치성 테스트가 동치성 코드가 정확히 작동하는 것을 검증하는 데 실패하면,

곱하기 테스트도 역시 실패하게 된다.

이게 TDD 를 하면서 관리해야 할 위험요소다 !

 

솔직히 말하자면


테스트 목록의 첫 번째인 테스트를 하기 위해 작은 발걸음을 떼보자.

우선은 Dollar 객체와 비슷하지만 달러 대신 프랑(Franc)을 표현할 수 있는 객체가 필요하다.

Dollar 와 비슷하게 작동하는 Franc 객체를 만들면

단위가 섞인 덧셈 테스트를 작성하고 돌려보는 데 더 가까워질 것 !

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

자 그럼 Dollar 를 복사해서 Franc 으로 붙여넣자.

여기서 주의해야 할 점 !

복사..? 코드 중복..? 이런 건 일단 신경 쓰지 말자.

TDD 의 리듬을 기억하자.

  1. 테스트 작성
  2. 컴파일되게 하기
  3. 실패하는 지 확인
  4. 실행하게 만듬
  5. 중복 제거

처음 네 단계는 가능한 빨리 진행해야 한다.

거기에 도달하기 위해서는 어떤 잘못도 저질러도 된다.

  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
class Franc {
    private int amount;

    public Franc(int amount) {
        this.amount = amount;
    }

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

    @Override
    public boolean equals(Object obj) {
        Franc franc = (Franc) obj;
        return amount == franc.amount;
    }
}
  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF

 

돌아온 ‘모두를 위한 평등’


  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times

우리는 이전 테스트를 빠르게 통과하기 위해서 코드를 복사해서 붙이는

엄청난 죄를 저질렀다..🫠 이제 청소할 시간이다.

Dollar 와 Franc 을 Money 라는 공통 상위 클래스로 묶어서 상속을 받으면 어떨까?

그러고 Money 클래스가 공통의 equals 코드를 갖게 하면 어떨까?

public class Money {
    protected int amount;
}
class Dollar extends Money {
    ...
}

Money 클래스에 amount 변수를 추가하고 하위 클래스에서도 변수를 볼 수 있도록

접근제한자를 private 에서 protected 로 변경했다.

public boolean equals(Object obj) {
    Money money = (Money) obj;
    return amount ==  money.amount;
}

casting 부분을 변경하고 이 메소드를 Dollar 에서 Money 로 옮기자.

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

이전에 Franc 의 equals 테스트 하던 부분을 지우고 Dollar 테스트 하는 곳에 합치자.

리팩토링을 하면서 코드가 원래 있어야 하는 곳으로 위치를 변경해준 것이다.

이 역시도 중복이다.. 이건 나중에 고치자 ㅎ

이제 Franc 가 Money 를 상속받도록 변경해준다.

class Franc extends Money {
    ...
}

 

사과와 오렌지


You can’t compare apples and oranges.
서로 다른 걸 비교할 수 없다는 뜻 !
  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 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)));
}

테스트 코드에 마지막 줄을 추가해봤다.

우리의 예상대로라면 5Franc 과 5Dollar 는 달라야 한다.

하지만 테스트는 실패한다.

Franc 과 Dollar 의 금액과 클래스가 서로 동일할 때만 두 Money 가 같은 것이다.

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

Money 의 equals 메소드를 수정하자.

좀 지저분해보여도 참자 🙋‍♂️ 아직 통화(currency) 개념이 없다.

 

객체 만들기


  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • Franc 와 Dollar 비교하기
  • 통화

이제 공통 times 코드를 처리해보자.

그렇기 때문에 혼합된 통화 간의 연산을 다루어야 한다.

우선 Dollar 와 Franc 의 times 코드가 거의 똑같다.

하위 클래스에 대한 직접적인 참조를 줄이기 위해 Money 에 Dollar 를 반환하는

팩토리 메소드를 추가하자.

테스트 코드에선 이런 식으로 나타날 것이다.

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

구현 코드는 Dollar 를 생성해서 반환하자.

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

이제 테스트 선언부의 Dollar 를 Money 로 바꿔주면 Dollar 에 대한 참조가 사라진다.

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

일단 Money 에는 times 가 정의되지 않아서 컴파일이 되지 않는다.

아직 구현할 준비가 되지 않았으니 Money 를 abstract class 로 바꾸고 Money.times() 를 선언하자.

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

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

팩토리 메소드의 반환 타입도 Money 로 바꿔주자.

이제 테스트 코드의 모든 곳에서 사용할 수 있다.

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(new Franc(5).equals(new Franc(5)));
    assertFalse(new Franc(5).equals(new Franc(6)));
    assertFalse(new Franc(5).equals(Money.dollar(5)));
}

이제 클라이언트가 Dollar 라는 이름의 하위 클래스 존재를 알지 못한다.

하위 클래스의 존재를 테스트에서 decoupling 함으로써 어떤 모델 코드에도 영향을 주지 않고

상속 구조를 마음대로 변경할 수 있음 !

Franc 에 대한 부분도 동일하게 변경하자.

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

하지만 testFrancMultiplication 을 확인해보면 testMultiplication 에서 동일한 로직을

다 검사하는 것을 볼 수 있다.

그렇다면 이 테스트를 지워야 할까?

일단은 그대로 두고 지켜보자

 

우리가 사는 시간


  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • Franc 와 Dollar 비교하기
  • 통화
  • testFrancMultiplication 을 지워야할까?

통화를 표현하기 위해 복잡간 객체가 필요할 수도 있지만,

일단 문자열로 표현해보자.

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

우선 Money 에 currency() 메소드를 선언하자.

abstract class Money {
    abstract String currency();
}

그 다음 하위 클래스에서 이를 구현하자.

class Dollar extends Money {
    @Override
    String currency() {
        return "USD";
    }
}

class Franc extends Money {
    @Override
    String currency() {
        return "CHF";
    }
}

일단 이렇게 구현했지만, 우리는 두 클래스를 모두 포함하는 동일한 구현이 좋다.

통화를 인스턴스 변수에 저장하고, 메소드에서는 그걸 그냥 반환하도록 바꾸자.

abstract class Money {
    protected String currency;
    String currency() {
        return currency;
    }
}

class Dollar extends Money {
    public Dollar(int amount) {
        this.amount = amount;
        currency = "USD";
    }
}

class Franc extends Money {
    public Franc(int amount) {
        this.amount = amount;
        currency = "CHF";
    }
}

하지만 여기서 문자열 “USD” 와 “CHF” 를 정적 팩토리 메소드로 옮긴다면 두 생성자가 동일해지고,

그렇다면 공통 구현을 만들 수 있을 것이다.

abstract class Money {
    public 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");
    }
}

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

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

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

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

이제 times() 를 상위 클래스로 올리고 하위 클래스들을 제거할 준비가 거의 다 됐다.

 

흥미로운 시간


  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • Franc 와 Dollar 비교하기
  • 통화
  • testFrancMultiplication 을 지워야할까?

지금 두 하위 클래스가 거의 동일하다.

이제 Money 를 나타내기 위한 단 하나의 클래스를 만들어보자.

이전 테스트에서 times 안에서 팩토리 메소드를 사용하도록 변경했지만,

다시 일보 후퇴를 해보자.

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

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

이제 거의 다 왔다 !

Franc.times() 가 Money 를 반환하도록 고쳐보자.

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

컴파일러가 Money 를 콘크리트 클래스로 바꿔야 한다고 한다.

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

임시로 조치하고 테스트를 돌리니 빨간 막대가 나온다.

에러 메시지를 더 잘 보기 위해 임시로 toString() 메소드를 정의하고 다시 테스트를 돌려보자.

abstract class Money {
    public String toString() {
        return amount + " " + currency;
    }
}

 

 

답은 맞았는데 클래스가 다르다.

문제는 equals() 구현에 있다.

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

실제로 검사해야 할 것은 클래스가 같은지가 아니라 currency 가 같은지 여부이다.

일단은 정석대로 원래대로 코드를 되돌리고 다시 테스트를 추가하면서 진행해보자.

class Franc extends Money {
    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 를 비교해야 한다.

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

이제 Franc.times() 에서 Money 를 반환해도 테스트가 여전히 통과된다.

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

Dollar.times() 에도 동일하게 적용된다.

이제 두 구현이 동일해졌으니 상위 클래스로 끌어올릴 수 있다 ~!

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

이제 우리는 아무것도 안 하는 하위 클래스를 제거할 수 있다 !

 

모든 악의 근원


  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • Franc 와 Dollar 비교하기
  • 통화
  • testFrancMultiplication 을 지워야할까?

두 하위 클래스는 이제 생성자밖에 없으니 하위 클래스를 제거해보자.

이제 Money.franc() 와 Money.dollar() 를 고쳐보자.

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

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

이제 Dollar 에 대한 참조는 하나도 남아있지 않으니 제거하자.

반면 Franc 은 이전에 작성했던 테스트 코드에서 아직 참조하고 있다.

이 테스트 코드를 지워도 될지 다른 동치성 테스트 코드를 확인해보자.

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

충분한 테스트인 것 같다. 오히려 좀 과한 느낌이 드니 세번째와 네번째 assertion 문은 지우자.

Franc 을 참조하고 있는 테스트와 Franc 클래스도 지우면 된다.

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)));
}
  • $5 + 10CHF = $10 (환율이 2:1 인 경우)
  • $5 X 2 = $10
  • amount 를 private 으로 만들기
  • Dollar 부작용 (side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • Franc 와 Dollar 비교하기
  • 통화
  • testFrancMultiplication 을 지워야할까?

또한 달러와 프랑에 대해 별도의 테스트가 있었지만 현재 로직상에 차이가 없기 때문에

testFrancMultiplication 역시 지우자.

이제 덧셈을 다룰 준비가 됐다 !

잠시 숨 좀 고르고… 덧셈부터는 다음 글에서 구현해보겠다.

 

 

728x90
저작자표시
    '📚 Book/🧪 TDD' 카테고리의 다른 글
    • TDD :: TDD 를 시작하며
    TDD, 다중화폐
    jujuwon
    jujuwon
    댓글쓰기
    TDD :: TDD 를 시작하며
    이전 글
    TDD :: TDD 를 시작하며

    티스토리툴바