Contents
테스트 주도 개발 2주차
   Oct 21, 2022     22 min read

스터디 2주차(22.10.16 ~ 22.10.22)

5장. 솔직히 말하자면(Franc-ly Speaking)


할일 목록 : 5CHF * 2 = 10CHF 추가

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

역시나 처음 할일 목록 체크가 제일 중요하다.

추가된 할일은 5CHF * 2 - 10CHF 이다.

프랑(Franc) 객체 생성하기

class Franc{
    private int amount;

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

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

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

}

Franc 객체를 만드는 이유?

Dollar 객체와 비슷하게 작동하는 Franc 객체는 단위가 섞인 덧셈 테스트를 작성하고 돌려보는 데 더 가까워 질 것이다. 그래서 만들었다.

현재까지 한것을 검토

  • 큰 테스트를 공략할 수 없다 그래서 진전을 나타낼 수 있는 작은 테스트를 만들었다.
  • 중복을 만들고 조금 고쳐서 테스트를 작성했다.
  • 설상가상으로 모델 코드까지 도매금으로 복사하고 수정해서 테스트를 통과했다.
  • 중복이 사라지기 전에는 집에 가지 않겠다고 약속했다.

이 장에서 켄트벡의 글을 보며 사고의 흐름을 좀 읽을 수 있을 것 같았다.

이 사람은 자신이 오류를 범했던 것과 생각을 서적에 잘 녹일라고 노력한게 보인다. 꼼꼼히하는 습관 그리고 내가 어디까지 왔는지 확인하는 습관 정말 중요한 것 같다.


6장 돌아온 ‘모두를 위한 평등’(Equality for All, Redux)


할일 목록 : 공용 equals, 공용 times 추가

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

두 클래스(Dollar, Franc)의 공통 상위 클래스 만들기

Untitled

  • Money 클래스가 공통의 equals 코드를 갖게 하면 어떨까?
//Money
class Money
  • 아래와 같이해도 아무 이상없다. 테스트는 여전히 잘 된다. 이제 amount 인스턴스 변수를 Money로 옮길 수 있다.
//Dollar
class Dollar extends Money{
    private int amount;
}
  • 하위 클래스에서도 변수를 볼 수 있도록 가시성을 private에서 protected로 변경했다.
//Money
class Money{
    protected int amount;
}

이제 equals() 코드를 위로 올리는 일을 할 수 있다.

이제 우선으로 임시변수를 선언하는 부분을 변경하자.

//Dollar
public boolean equals(Object object){
    Money dollar = (Dollar) object;
    return amount == dollar.amount;
}
  • 다음 cast 부분을 변경
//Dollar
public boolean equals(Object object){
    Money dollar = (Money) object;
    return amount == dollar.amount;
}
  • 임시 변수 이름 변경
//Dollar
public boolean equals(Object object){
    Money money = (Money) object;
    return amount == money.amount;
}
  • 이제 equals() 메소드를 Dollar에서 Money로 옮길 수 있게되었다.
//Money
public boolean equals(Object object){
    Money money = (Money) object;
    return amount == money.amount;
}

달러 중복제거 성공 !

다음은 Franc.equals()를 제거해보자.

저자는 동치성 테스트가 Franc 끼리의 비교에 대해서는 다루지 않는다는 점에 주목하자고 한다.

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
class Franc extends Money{
    prviate int amount;
}

Money 클래스에 있는 필드를 이용하면 Frnac의 amount 필드를 제거할 수 있다.

//Franc
class Franc extends Money{
}

Franc.equals()Money.eqauls() 와 거의 비슷해 보인다. 이 부분을 완전히 똑같이 만들 수 있다면 프로그램의 의미를 변화시키지 않고도 Franc의 equals()를 지워버릴 수 있게 된다.

우선 임시 변수의 선언 부분을 지우자.

  • Franc → Money로 변경
//Franc
public boolean equals(Object object){
    Money franc = (Franc)object;
    return amount == franc.amount;
}
  • cast 부분 수정
//Franc
public boolean equals(Object object){
    Money franc = (Money)object;
    return amount == franc.amount;
}
  • 임시 변수의 이름도 상위 클래스에 맞게 고치자
//Franc
public boolean equals(Object object){
    Money money = (Money)object;
    return amount == money.amount;
}

할일 목록 : Franc과 Dollar 비교하기 추가

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

6장에서 해온 작업

  • 공통된 코드를 첫 번째 클래스(Dollar)에서 상위 클래스(Money)로 단계적으로 옮겼다.
  • 두 번째 클래스(Franc)도 Money의 하위 클래스로 만들었다.
  • 불필요한 구현을 제거하기 전에 두 equals() 구현을 일치시켰다.

7장 사과와 오렌지


할일 목록

  • $5 + 10CHF = $10(환율이 2:1일 경우)
  • $5 * 2 = $10
  • amount를 private으로 만들기
  • Dollar 부작용(side effect)?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF * 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)); // 두 클래스 객체 비교 -> X
}

결론 : 실패

동치성 코드에서는 Dollar가 Franc과 비교되지 않는지 검사해야 한다. → 통화의 개념이 맞는지 검사?

오직 금액과 클래스가 서로 동일할 때 두 Money가 서로 같은 것이다.

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

7장에서 한 작업

  • 우릴 괴롭히던 결함을 끄집어내서 테스트에 담아냈다.
  • getClass( ) 으로 테스트를 통과하게 만들었다.

할일 목록 : Franc과 Dollar 비교하기 완료 , 통화? 추가

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

8장. 객체 만들기


할일 목록

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

Dollar와 Franc 안에서 times( ) 메소드에 구현부분이 비슷하다.

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

위의 코드들은 반환할 때 각자의 객체를 계산해서 반환한다. 하지만 이것들을 공통으로 묶어서 하나로 만들어주는 것 Money를 이용하는 방법이다.

  • 양쪽 모두 반환을 Money로 반환하게 만들면 비슷하게 만들 수 있게된다.
//Franc
Money times(int multiplier){
    return new Franc(amount * multiplier);
}

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

두 하위 클래스는 많은 일을 하지 않으므로 제거하고 싶지만 너무 큰 단계로 가는 것은 적절치 않다.

Money에 Dollar로 반환하는 팩토리 메소드(Factory Method) 도입

public void testMultiplication(){
        Money five = Money.dollar(5);
        assertEquals(Money.dollar(10), five.times(2));
        assertEquals(Money.dollar(15), five.times(3));
}
  • 구현 코드는 Dollar를 생성하여 반환한다.
//Money
static Dollar dollar(int amount){
    return new Dollar(amount);
}
  • Dollar에 대한 참조가 사라지길 바라므로 테스트의 선언부를 바꿔야한다.
    • Dollar → Money
public void testMultiplication(){
        Money five = Money.dollar(5); //Dollar → Money
        assertEquals(Money.dollar(10), five.times(2));
        assertEquals(Money.dollar(15), five.times(3));
}

컴파일러가 Money에는 times()가 없기때문에 에러가 뜬다.

  • abstract 추가, abstract Money times() 메서드 추가 → 팩토리 메소드의 선언을 바꿀 수 있게된다.
//Money
abstract class Money {
     abstract Money times(int multiplier);
}
  • 팩토리 메소드 선언 바꾼다.
static Money dollar(int amount) {
        return new Dollar(amount);
    }
  • 팩토리메소드를 이용해서 테스트 코드의 나머지 모든 곳에 적용해보자.
    • new Dollar(10) → Money.dollar(10)
    • new Dollar(15) → Money.dollar(15)
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(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));
}

//변경 후
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)));
}

어떤 클라이언트 코드도 Dollar라는 이름의 하위 클래스가 있다는 사실을 알지 못한다.

하위클래스의 존재를 테스트에서 분리(decoupling)함으로써 어떤 모델 코드에도 영향을 주지 않고 상속 구조를 마음대로 변경할 수 있게 됐다.

SOLID의 제 1원칙 (단일책임원칙?)인가?

  • testFrancMultiplication 수정
public void testFrancMultiplication(){
    Money five = Money.franc(5);
    assertEquals(Money.franc(10), five.times(2));
    assertEquals(Money.franc(15), five.times(3));
}
  • Money에 Franc
//Money
static Money franc(int amount){
    return new Franc(amount);
}

할일 목록 : testFrancMultiplication을 지워야 할까? 추가

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

8장에서 한 작업

  • 동일한 메소드(times)의 두 변이형 메서드 서명부를 통일시킴으로써 중복 제거를 향해 한 단계 더 전진했다.
  • 최소한 메소드 선언부만이라도 공통 상위 클래스(superclass)로 옮겼다.
  • 팩토리 메소드를 도입하여 테스트 코드에서 콘크리트 하위 클래스의 존재 사실을 분리해냈다.
  • 하위 클래스가 사라지면 몇몇 테스트는 불필요한 여분의 것이 된다는 것을 인식했다.

9장. 우리가 사는 시간


할일 목록

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

할일 목록에서 어떤 걸 하면 귀찮고 불필요한 하위 클래스를 제거하는 데 도움이 될까? 통화 개념을 도입해보면 어떨까?

그렇다면 통화 개념을 어떻게 구현하길 원하는가 ?

통화를 표현하기 위한 복잡한 객체들을 원할 수도 있다. 그리고 그 객체들이 필요한 만큼만 만들어지도록 하기 위해 경량 팩토리(flyweight factories)를 사용할 수 있을 것이다.

하지만 처음이니깐 문자열을 써서 사용하자.

public void test Currency(){
    assertEquals("USD", Money.dollar(1).currency());
    assertEquals("CHF", Money.franc(1).currency());
}
  • Money에 currency() 구현 후 두 하위 클래스(Franc, Dollar)를 구현
//Money
abstract String currency();
//Franc
String currency(){
    return "CHF";
}
//Dollar
String currency(){
    return "USD";
}

두 클래스(Franc, Dollar)를 모두 포함할 수 있는 동일한 구현하고싶다.

통화를 인스턴스 변수에 저장하고 메서드에서는 그냥 그것을 반환하게 만들 수 있을 것 같다.

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

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

이제 두 currency()가 동일하므로 변수 선언과 currency() 구현을 둘 다 위로 올릴(push up) 수 있게 됐다.

//Money
protected String currency;
String currency(){
    return currency;
}

문자열 ‘USD’‘CHF’를 정적 팩토리 메서드로 옮긴다면 두 생성자가 동일해질 것이다.

그렇게되면 공통 구현을 만들 수 있다.

  • 생성자에 인자를 추가
//Franc
Franc(int amount, String currnecy){
    this.amount = amount;
    this.currency = "CHF";
}

결과 생성자를 호출하는 코드 두 곳이 깨진다.

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

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

times()를 정리하자

//Franc
Money times(int multiplier){
    return Money.franc(amount * multiplier);
}
  • 팩토리 메소드가 ‘CHF’ 를 전달할 수 있다.
//Money
static Money franc(int amount){
    return new Franc(amount, "CHF");
}

그리고 마지막으로 인자를 인스턴스 변수에 할당할 수 있다.

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

}

Dollar를 위와 유사하게 수정할 수 있는지 확인해보자.

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

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

두 생성자가 동일해졌다. 구현을 상위클래스에 올리자.

//Money
Money(int amount, String currency){
   this.amount = amount;
   this.currency = currency;
}
//Franc
Franc(int amount, String currency){
    super(amount, currency);
}
//Dollar
Dollar(int amount, String currency){
    super(amount, currency);
}

할일 목록 : 통화? 제거

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

times()를 상위 클래스로 올리고 하위 클래스들을 제거할 준비가 거의 다 됐다. 하지만 일단 지금까지 한 것을 검토해보자.

9장 정리

  • 큰 설계 아이디어를 하다가 곤경에 빠짐 → 더 작은 작업으로 수행을 함
  • 다른 부분들을 호출자(팩토리 메서드)로 옮김으로써 두 생성자를 일치시켰다.
  • times()가 팩토리 메서드를 사용하도록 만들기 위해 리팩토링을 잠시 중단함
  • 비슷한 리팩토링(Franc에 했던 일을 Dollar에도 적용)을 한번의 큰 단계로 처리함.
  • 동일한 생성자들을 상위 클래스로 올렸다.

10장. 흥미로운 시간


할일 목록

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

두 클래스에 times() 구현이 거의 비슷하지만 완전히 동일하지 않는다. 동일하게 만들어주자

//Franc
Money times(int multiplier) {
    return Money.franc(amount * multiplier);
}
//Dollar
Money times(int multiplier) {
    return Money.dollar(amount * multiplier);
}
  • 이 둘을 동일하게 만들기 위한 명백한 방법은 없다. ..팩토리 메서드를 인라인시키면 어떻까?
//Franc
Money times(int multiplier) {
    return new Franc(amount * multiplier, "CHF");
}
//Dollar
Money times(int multiplier) {
    return new Dollar(amount * multiplier, "USD");
}

Franc에서는 인스턴스 변수 currency가 항상 ‘CHF’ 이므로 다음과 같이 할 수 있다.

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

잘된다. Dollar도 바꿔주자.

//Dollar
Money times(int multiplier){
    return new Dollar(amount * multiplier, currency);
}
  • Franc.times()가 Money를 반환하도록 고쳐보자. → Franc에서 Money로 변경
//Franc
Money times(int multiplier){
    return new Money(amount * multiplier, currency);
}

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

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

빨간 막대 → 에러 메시지 “expected:<Money.Franc@31abf> but was : <Money.Money@478a43>” 이라고 나온다.

더 나은 메시지를 보기위해서 toString()을 사용한다.

//Money
public String toString(){
   return amount + " " + currency;
}
  • 예외상황이 나온다.
    • 에러 메시지 : expected : <10 CHF> but was:<10 CHF>” 라고 나온다.

클래스가 다르다. Franc대신 Money가 왔기 때문이다. 문제는 equals()구현에 있다.

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

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

빨간막대 상황에서 테스트를 추가로 작성하지않고 보수적으로 접근해 초록 막대 일때로 돌아가서 다시해 생각해보자.

  • 다시 초록 막대기로 돌아가자.
//Franc
Money times(int multiplier){
    return new Franc(amount * multiplier, currency);
}
public void testDifferentClassEquality(){
    assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF")));
}

실패한다. equals() 코드는 클래스가 아니라 currency를 비교해야한다.

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

이제는 Franc.times()에서 Money를 반환해도 테스트가 여전히 통과하게 할 수 있다.

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

이제 Dollar.times()에도 적용해보자. 될까?

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

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

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

할일 목록 : 공용 times

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

이제 곱하기 구현했으니 하위클래드르을 제거할 수 있음

10장 정리

  • 두 times()를 일치시키기 위해 그 메서드들이 호출하는 다른 메서드들을 인라인 시킨 후 상수를 변수로 바꿔주었다.
  • 디버깅을 위해 테스트 없이 toString()를 사용했다.
  • Franc 대신 Money를 반환하는 변경을 시도한 뒤 그것이 잘 작동할지를 테스트가 되도록 했다.
  • 실험해본 걸 뒤로 물리고 또 다른 테스트를 작성했다. 테스트를 작동했더니 실험도 제대로 작동함

5장부터 10장까지의 정리 회고

차례대로 하는게 정말 착하게 작가가 해주는 것 같다.

하지만 중간에 팩토리 메소드, 경량화 패턴을 잘은 모르지만 한번 쭈욱 훑는 마음으로 읽었다.

소스코드를 쓰면서 좀 이해되는 것들이 있어서 너무 좋긴하다. TDD를 배우는 것도 배우는거지만 객체지향관점을 제대로 배우는 것 같아서 너무 좋다. ! 힘들지만 스터디를 포기하지 않고 꾸준히 해보자 !

giphy


참고 문헌 : 켄트 벡 지음, 김창준, 강규영 옮김 - Test-Driven Development