본문 바로가기
Books

테스트 주도 개발 정리 - (2)

by 밀러 (miller) 2022. 1. 19.

"테스트 주도 개발 (Test-Driven Development: By Example)" 6장 ~ 10장까지 읽고 정리한 글입니다.

 

 

[6장] 돌아온 ‘모두를 위한 평등’

테스트를 빨리 통과하기 위해 코드를 복사해서 붙이는 엄청난 죄를 저질렀다. 이제 청소할 시간으로, 할일 목록은 다음과 같다.

 

현재 할일 목록은 다음과 같다.

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar 부작용?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times

Dollar/Franc 두 클래스의 공통 상위 클래스로 Money 를 사용하자. 간단한 것부터, Money 클래스가 공통의 equals 를 가지는 경우로 시작하자.

 

중복 제거하기 - (1)

// Money.java
class Money {}

// Dollar.java
class Dollar extends Money {
    private int amount;
}

 

equals 를 상위 클래스로 올리기 위해 먼저 amount 를 위로 올리자. 그리고 하위 클래스에서도 변수를 볼 수 있도록 가시성을 private 에서 protected 로 변경한다.

 

중복 제거하기 - (2)

// Money.java
class Money {
    protected int amount;
}

// Dollar.java
class Dollar extends Money {}

 

이제 equals 코드를 상위클래스로 올리는 일을 할 수 있게 되었는데, equals 코드를 위로 올리는 과정은 아래와 같다.

 

중복 제거하기 - (3)

// 1. 임시변수 선언 부분을 변경
// Dollar.java
public boolean equals(Object object) {
    **Money** dollar = (Dollar) object;
    return amount == dollar.amount;
}

// 2. 캐스트 연산자 부분을 변경
// Dollar.java
public boolean equals(Object object) {
    Money dollar = (**Money**) object;
    return amount == dollar.amount;
}

// 3. 임시 변수의 이름을 변경
// Dollar.java
public boolean equals(Object object) {
    Money money = (Money) object;
    return amount == money.amount;
}

// 4. 상위 클래스 메서드로 변경
// Money.java
public boolean equals(Object object) {
    Money money = (Money) object;
    return amount == money.amount;
}

 

이제 Franc.equals() 를 제거할 수 있다. 하지만 Dollar 객체의 equals 에 대한 테스트만 만들고 Franc 모델 코드는 Dollar 의 모델 코드를 복사한 탓에, Franc 객체 간의 비교를 다루는 테스트가 없었다. 이런 경우 리팩토링하면서 실수했는데도 불구하고 테스트가 여전히 통과될 수도 있다. 따라서 적절한 테스트를 갖지 못한 코드로 리팩토링할 경우, 있으면 좋을 것 같은 테스트를 작성하라.

 

테스트 만들기 → 테스트 통과하기

// test
public void testEqaulity() {
    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 의 테스트가 없으므로 Dollar 의 테스트를 복사하여 만들었지만, 다시 중복이 발생했다. 따라서 모델 코드에서 중복을 제거하자.

 

중복 제거하기 - (1)

// 1. 상위 클래스 상속
// Franc.java
class Franc extends Money {
    private int amount;
}

// 2. 상위 클래스 protected 변수 상속
// Franc.java
class Franc extends Money {}

Dollar 와 마찬가지로 Franc 의 모델 코드도 상위클래스와 상위 멤버변수를 사용하여, 상위클래스로 equals() 를 보낼 수 있다.

 

중복 제거하기 - (2)

// 1. 임시변수 선언 부분을 변경
// Franc.java
public boolean equals(Object object) {
    **Money** franc = (Franc) object;
    return amount == franc.amount;
}

// 2. 캐스트 연산자 부분을 변경
// Franc.java
public boolean equals(Object object) {
    Money franc = (**Money**) object;
    return amount == franc.amount;
}

// 3. 임시 변수의 이름을 변경
// Franc.java
public boolean equals(Object object) {
    Money money = (Money) object;
    return amount == money.amount;
}

// 4. 상위 클래스 메서드로 변경
// Money.java
public boolean equals(Object object) {
    Money money = (Money) object;
    return amount == money.amount;
}

 

정리

위의 TDD 주기를 정리면 아래와 같다.

 

  • DollarFranc 두 클래스의 공통된 코드를 상위 클래스로 단계적으로 옮겼다.
  • 불필요한 구현을 제거하기 전에 먼저 두 equals() 구현을 일치시켰다.
    혹은 equals() 구현을 일치시키기 위해 불필요한 구현을 제거했다.

고찰

공통 상위 클래스로 하위 클래스의 코드를 옮기기 위해, 옮기고자 하는 하위 클래스의 멤버변수와 메서드를 전부 상위 클래스의 것으로 바꿔야 한다. 오직 신념 하나만 가지고 바라봐야 한다.

 

[7장] 사과와 오렌지

현재 할일 목록은 다음과 같다.

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar 부작용?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • FrancDollar 비교하기

FrancDollar 를 비교하면 어떻게 될까?

 

테스트 만들기

// test
public void testEquality() {
    ...
    assertFalse(new Franc(5).equals(new Dollar(5)));
}

 

위 테스트는 실패하므로 모델 코드를 수정하자.

 

진짜로 구현하기

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

 

모델 코드에서 클래스를 이런식으로 사용하는 것은 지저분하다. 이후 자바 객체의 용어를 사용하기보다 도메인에 맞는 용어를 사용하는 것으로 수정하자. 즉, 더 많은 동기가 있기 전에는 더 많은 설계를 도입하지 않는다!

 

[8장] 객체 만들기

현재 할일 목록은 다음과 같다.

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar 부작용?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • FrancDollar 비교하기
  • 통화?

equals 와 마찬가지로 times 의 코드가 거의 똑같다.

 

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

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

 

FrancDollar 모두 Money 를 반환하게 만들면 더 비슷하게 만들 수 있다

 

중복 제거하기 - (1)

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

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

 

여기서 하위 클래스에 대한 직접적인 참조가 적어진다면 하위 클래스를 제거하기 위해 한발더 다가섰다고 할 수 있다. 즉, 상위 클래스인 Money 에서 하위 클래스인 Dollar 를 반환하는 정적 팩토리 메서드 (factory method) 를 도입할 수 있다.

 

중복 제거하기 - (2)

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

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

 

여기서 하위 클래스에 대한 직접적인 참조를 줄이기 위해 Dollar 가 아닌 Money 를 반환하기를 원하므로, 아래와 같이 테스트를 수정한다. 팩토리 메서드를 사용할 경우, 어떤 클라이언트 코드도 Dollar 라는 이름의 하위 클래스가 있다는 사실을 인지하지 못한다. 따라서 하위 클래스의 존재를 분리 (decoupling) 함으로써 어떤 모델 코드에도 영향을 주지 않고, 상속 구조를 마음대로 변경할 수 있다. → 상위 클래스로 추상화 하는 이유!

 

테스트 수정하기

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

 

이 경우 Moneytimes() 가 정의되어있지 않으므로 컴파일 에러가 발생한다. 따라서 Money 를 추상 클래스로 변경 후, times() 메서드를 선언한다. 이 경우 팩토리 메서드에서 Money 를 반환해도 컴파일 에러가 발생하지 않는다.

 

중복 제거하기

// Money.java
abstract class Money {
    abstract Money times(int multiplier);

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

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

 

팩토리 메서드를 테스트 코드의 나머지 모든 곳에서 사용할 수 있다.

 

테스트 수정하기

// test
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(Money.Franc(5).equals(Money.Franc(5));
    assertFalse(Money.Franc(5).equals(Money.Franc(6));
    assertFalse(Money.Franc(5).equals(Money.Dollar(5));
}

 

정리

위의 TDD 주기를 정리면 아래와 같다.

 

  • 동일한 메서드 times() 의 메서드 선언부를 통일시켜 중복 제거를 위한 한 단계를 나아갔다.
  • times() 의 메서드 선언부를 상위클래스로 옮겼다.
  • 팩토리 메서드를 도입하여 테스트 코드에서 콘크리트 하위 클래스의 존재 사실을 분리했다.
    (=테스트 코드에서 하위 클래스의 존재를 모른다.)

고찰

클래스의 상위 클래스로 메서드를 옮기고 싶다면, 각 하위 클래스에서 먼저 반환타입, 캐스트 연산자, 로컬 변수 등을 하위 클래스에서 상위 클래스로 변경

 

→ 모든 하위 클래스에서 변경이 완료되면 상위 클래스로 함수 선언부와 코드를 이동하고 abstract, static 등의 예약어와 함께 공통 메서드 구현

 

그리고 바꾸는 과정에서 테스트를 다시 수정해, 테스트가 상위 클래스에 맞게 컴파일되도록 변경

 

[9장] 우리가 사는 시간 (times)

현재 할일 목록은 다음과 같다.

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar 부작용?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • FrancDollar 비교하기
  • 통화?
  • testFrancMultiplication 제거

귀찮고 불필요한 하위 클래스를 제거하기 위해 통화 개념을 도입해보자. 통화 개념을 어떻게 도입해야할까? 아니, 통화 개념을 어떻게 테스트 해야할까?

 

통화를 표현하기 위한 복잡한 객체들을 원할 수 있고, 객체들이 필요한 만큼만 만들어지도록 하기 위해 경량 팩토리 (flyweight factories) 를 사용할 수 있을 것이다. 하지만 당분간은 그런 것들 대신 문자열을 사용해 테스트를 만들자.

 

테스트 만들기

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

 

가짜로 구현하기 - (1)

// Money.java
abstract String currency();

// Franc.java
String currency() {
    return "CHF";
}

// Dollar.java
String currency() {
    return "USD";
}

 

통화를 인스턴스 변수에 저장하고, 메서드에서는 인스턴스 변수를 반환하게 하는 쪽이 더 좋을 것 같다.

 

가짜로 구현하기 - (2)

// Franc.java
private String currency;

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

String currency() {
    return currency;
}

// Dollar.java
private String currency;

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

String currency() {
    return currency;
}

 

이제 두 currency() 가 동일하므로 변수 선언과 currency() 구현을 둘 다 상위 클래스로 올릴 수 있게 되었다.

앞의 6장에서 했던 것과 마찬가지로, 공통 상위 클래스로 하위 클래스의 코드를 옮기기 위해, 옮기고자 하는 하위 클래스의 멤버변수와 메서드를 전부 상위 클래스의 것으로 바꿔야 한다. 오직 신념 하나만 가지고 바라봐야 한다.

 

중복 제거 - (1)

// Money.java
protected String currency;

String currency() {
   return currency;
}

 

인스턴스 변수에 선언되는 문자열 “USD”"CHF" 를 정적 팩토리 메서드로 옮긴다면 두 생성자가 동일해질 것이고, 따라서 공통 구현을 만들 수 있으므로 생성자에 인자를 추가하자.

 

중복 제거 - (2)

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

// 에러 발생!
// Money.java
static Money franc(int amount) {
    return new Franc(amount, null);
}

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

 

생성자를 호출하는 코드 2곳이 껴져서 수정했더니, Franctimes 가 팩토리 메서드가 아닌 생성자를 호출하는 부분이 눈에 띈다. 현재 구현중이었던 통화와 times 수정 중 어떤 것을 먼저 고쳐야할까? 지금 하는 일을 중단하지 않아야하니까 중단하지 않는 것이 맞지만, 중단이 짧은 경우 하는 일을 중단해도 괜찮다. 하지만, 하던 일을 중단하고 다른 일을 하는 상태에서 그 일을 또 중단하지는 않는다.

 

중단하고 다른 정리하기

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

생성자 대신 팩토리 메서드 호출로 수정되었고, 이제 팩토리 메서드가 "CHF" 를 인자로 전달할 수 있다.

 

중복 제거 - (3) ← 중단에서 복귀하기

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

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

마찬가지로 Dollar 에 대해서도 팩토리 메서드를 수정할 수 있다.

 

중복 제거 - (4)

// Money.java
static Money dollar(int amount) {
    return new Dollar(amount, "USD");
}

// Dollar.java
Dollar(int amount, String currency) {
    this.amount = amount;
    this.currency = currency;
}

// 중단하고 다른 정리하기
// Dollar.java
Money times(int multiplier) {
    return Money.dollar(amount * multiplier);
}

지금과 같은 일은 TDD 를 하는 동안 계속 해주어야 하는 일종의 조율이다. 하지만 종종걸음으로 진행하는 것이 답답하면 보폭을 조금 넓히고, 성큼성큼 걷는 것이 불안하면 보폭을 줄이자.

이제 두 생성자가 동일해졌으므로, 구현을 상위 클래스에 올리자.

 

중복 제거 - (5)

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

// Franc.java
Franc(int amount, String currency) {
    super(amount, currency);
}

// Dollar.java
Dollar(int amount, String currency) {
    super(amount, currency);
}

 

 

정리

위의 TDD 주기를 정리면 아래와 같다.

 

  • times() 가 위 팩토리 메서드를 사용하도록 만들기 위해 리팩토리를 잠시 중단시켰다.
  • 다른 부분들을 팩토리 메서드로 옮김으로써 두 생성자를 일치시켰다.
  • 큰 설계 아이디어를 다루다가 조금 곤경에 빠졌고, 좀 전에 주목했던 더 작은 작업을 수행했다.

[10장] 흥미로운 시간 (times)

현재 할일 목록은 다음과 같다.

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar 부작용?
  • Money 반올림?
  • equals()
  • hashCode()
  • Equal null
  • Equal object
  • 5CHF X 2 = 10CHF
  • Dollar/Franc 중복
  • 공용 equals
  • 공용 times
  • FrancDollar 비교하기
  • 통화?
  • testFrancMultiplication 제거

times() 가 구현이 거의 비슷하긴 하지만 아직 완전히 동일하지는 않다.

 

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

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

 

위 둘을 동일하게 만들기 위한 명백한 방법이 없어보인다. 하지만 전진하기 위해 물러서야 할 때도 있는 법이다! 앞의 9장에서 하위 클래스의 생성자 호출 대신 상위 클래스의 팩토리 메서드로 수정했지만, 다시 되돌려보자.

 

중복 제거하기 - (1)

// Franc.java
Money times(int multiplier) {
    return new Franc(amount * multiplier, "CHF");
}

// Dollar.java
Money times(int multiplier) {
    return new Dollar(amount * multiplier, "USD");
}

 

그런데 FrancDollar 의 인스턴스는 currency 를 멤버변수로 가지므로 다음과 같이 쓸 수 있다.

 

중복 제거하기 - (2)

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

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

 

여기서 하위 클래스Franc 의 생성자를, 상위 클래스 Money 의 생성자로 바꿀 수 있을까? 우리에겐 테스트 코드들이 있으므로, 고민하는 대신 그냥 수정하고 테스트를 돌려서 컴퓨터에게 직접 물어보자. 즉, 실험을 돌려보자. 실험을 진행하기 위해 Franc.times() 가 그냥 Money 를 반환하도록 고친다.

 

실험하기 - (1)

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

// Money.java
Money {}

Money times(int amount) {
    return null;
}

 

추상 클래스 Money 의 생성자를 호출할 경우, 추상 클래스이므로 콘크리트 클래스로 변경해야 컴파일 에러가 사라진다. 그리고 abstract 키워드가 붙은 메서드를 가짜로 구현한다. 테스트를 실행하면 빨간 막대를 볼 수 있다. 하지만 에러메시지가 기대만큼 도움이 되는 메시지가 아니므로, 더 나은 메시지를 보기 위해 toString() 을 정의하자.

 

테스트없이 코드 작성하기

// Money.java
public String toString() {
    return amount + "  " + currency;
}

 

테스트가 없이 코드를 작성했다! 하지만 다음의 이유로 테스트보다 코드를 먼저 작성할 수 있다.

  • 기능 구현이 아닌 화면에 나오는 결과를 보려고 했다.
  • toString() 은 디버그 출력에만 쓰이기 때문에, 잘못 구현됨으로 인해 얻게 될 리스크가 적다.
  • 이미 빨간 막대 상태인데 이 상태에서는 새로운 테스트를 작성하지 않는게 좋을 것 같다.

 

하지만 출력 결과 또한 동일하다. 즉, 답은 맞았는데 클래스가 다르다. 문제는 equals() 구현에 있다. 정말로 검사해야할 것은 클래스가 같은지가 아니라 currency 가 같은지 여부이기 때문이다.

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

 

현재 빨간 막대인 상황이므로 테스트를 추가로 작성하지 않고, 보수적인 방법을 따라 변경된 코드를 되돌려서 초록 막대 상태로 돌아가야 한다. 그러고 나서 equals() 를 위해 테스트를 고친 뒤 구현 코드를 고칠 수 있게 되고, 그 후에야 원래 하던일 일을 다시 할 수 있다.

 

다시 실험 전으로 돌아가고, equals() 를 위해 테스트를 만들자. Money 생성자로 생성된 객체와 Franc 생성자로 생성된 객체는, 클래스는 다를 수 있어도 실제로는 같은 객체여야 한다.

 

테스트 만들기

// 실험 전의 상태
// Franc.java
Money times(int multiplier) {
    return new Franc(amount * multiplier, currency);
}

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

// 테스트 고치기
// test
public void testDifferentClassEquality() {
    assertTrue(new Money((10, "CHF"), new Franc(10, "CHF")));
}

 

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

 

진짜로 구현하기

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

 

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

 

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

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

// 구현이 동일하므로 상위 클래스로 이동
// Money.java
Money times(int multiplier) {
    reutrn new Money(amount * multiplier, currency);
}

 

정리

위의 TDD 주기를 정리하면 아래와 같다.

  • 디버깅을 위해 테스트 없이 toString() 을 작성했다.
  • 반환타입의 클래스를 상위클래스로 변경을 시도한 뒤 (실험) , 잘 작동되는지는 테스트를 돌렸다.

고찰

현재의 중복을 제거하기 위해, 이전의 중복 제거를 위해 수정했던 부분을 되돌리고 다시 원래의 코드로 돌렸다. 그리고 중복을 제거하기 위해 구조적으로 맞는지 고민하기보다, 먼저 실험을 통해 컴퓨터에 직접 물어보는 방법을 택한다. 또한 toString() 과 같이 디버깅을 위한 메서드를 테스트 없이 작성하기도 했다.

 

댓글