리듬을 보자.
이 책의 1부에서는 테스트에 의해 주도되는 전형적인 모델 코드를 개발한다.
테스트 주도 개발의 리듬을 보자.
- 재빨리 테스트 하나 추가
- 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인
- 코드 수정
- 모든 테스트를 실행하고 전부 성공하는지 확인
- 리팩토링을 통해 중복 제거
다중 통화를 지원하는 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 의 리듬을 다시 기억해보자.
- 재빨리 테스트 하나 추가
- 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인
- 코드 수정
- 모든 테스트를 실행하고 전부 성공하는지 확인
- 리팩토링을 통해 중복 제거
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 주기는 다음과 같다.
- 테스트를 작성한다. 원하는 인터페이스를 개발하자.
- 실행 가능하게 만든다. 일단 진짜 돌아가게만 (나중에 사과하면 된다)
- 올바르게 만든다. 잘못했던 거 싹싹 빌기.. 그리고 중복 제거하기
우리의 목적은 작동하는 깔끔한 코드 를 얻는 것 !
사실 말이 쉽지 굉장히 어려운 일이다.
그렇다면 일단 분할 정복하자. 먼저 “작동하는” 에 해당하는 부분을 먼저 해결하고
“깔끔한 코드” 를 나중에 해결하자.
우리의 할일 목록을 다시 살펴보자.
- $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 으로 변경 가능한지 이해가 안 된다면 여기를 보자.
class Dollar {
private int amount;
...
}
- $5 + 10CHF = $10 (환율이 2:1 인 경우)
$5 X 2 = $10amount 를 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 의 리듬을 기억하자.
- 테스트 작성
- 컴파일되게 하기
- 실패하는 지 확인
- 실행하게 만듬
- 중복 제거
처음 네 단계는 가능한 빨리 진행해야 한다.
거기에 도달하기 위해서는 어떤 잘못도 저질러도 된다.
- $5 + 10CHF = $10 (환율이 2:1 인 경우)
$5 X 2 = $10amount 를 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 = $10amount 를 private 으로 만들기Dollar 부작용 (side effect)?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF X 2 = 10CHF
돌아온 ‘모두를 위한 평등’
- $5 + 10CHF = $10 (환율이 2:1 인 경우)
$5 X 2 = $10amount 를 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 = $10amount 를 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 = $10amount 를 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 = $10amount 를 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 = $10amount 를 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 = $10amount 를 private 으로 만들기Dollar 부작용 (side effect)?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF X 2 = 10CHF- Dollar/Franc 중복
공용 equals공용 timesFranc 와 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 = $10amount 를 private 으로 만들기Dollar 부작용 (side effect)?- Money 반올림?
equals()- hashCode()
- Equal null
- Equal object
5CHF X 2 = 10CHFDollar/Franc 중복공용 equals공용 timesFranc 와 Dollar 비교하기통화testFrancMultiplication 을 지워야할까?
또한 달러와 프랑에 대해 별도의 테스트가 있었지만 현재 로직상에 차이가 없기 때문에
testFrancMultiplication
역시 지우자.
이제 덧셈을 다룰 준비가 됐다 !
잠시 숨 좀 고르고… 덧셈부터는 다음 글에서 구현해보겠다.