[Test] 테스트 주도 개발 (5 ~ 10장)

DaeHoon·2022년 10월 22일
0

TDD

목록 보기
2/9

5. 솔직히 말하자면

5CHF * 2 = 10CHF

  @Test
  public void testFrancMultiplication(){
    Franc five = new Franc(5);
    assertEquals(new Franc(10), five.times(2));
    assertEquals(new Franc(10), five.times(2));
  }
  • 5CHF * 2 = 10CHF 테스트에 접근하기 위해 아래와 같은 테스트를 작성해보자. 테스트에 접근하기 위해 위와 같은 테스트를 작성해보자.
  • Franc라는 객체를 지정해주지 않아 테스트가 컴파일도 되지 않는 빨강 상태

Franc

public 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;
  }
}
  • 초록 막대로 변경시키기 위해 Dollar 객체의 코드를 그대로 복붙하자.

TDD를 위한 단계 (Remind)

  • 테스트 작성
  • 컴파일되게 하기
  • 실패하는지 확인하기 위해 실행 (여기까지가 빨강 막대)
  • 실행하게 만듦 (초록 막대)
  • 중복 제거 (리팩토링)

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

공용 equals

public class Money {
}
  • 위에서 Franc에 대한 테스트를 통과하기 위해 Dollar의 코드를 복붙해서 통과시켰다.
  • 기능도 똑같아 코드도 비슷해 중복을 없애기 위해 Money라는 상위 클래스를 정의하자
public class Dollar extends Money{
	...
}

public class Franc extends Money{
	...
}

public class Money {
  protected int amount; //  하위 클래스에서도 변수에 접근할 수 있게 접근 제어자를 protected로 선언
}
  • Dollar와 Franc 객체를 Money를 상속받게 수정한다. 여전히 테스트는 잘 돈다.
  • 그리고 amount 변수를 Money로 옮겨보자
public class Money {
  protected int amount;

  public boolean equals(Object object){
    Money money = (Money) object;
    return amount == money.amount;
  }
}
  • 마지막으로 Money 객체에 Dollar가의 equals 메서드를 옮겨보자

Test

  @Test
  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()를 제거하기 위해 위와 같은 테스트를 미리 작성해놓자. 저자는 달러와 프랑에 대한 테스트를 중복사용 했다고 절규하고 있다.
public class Franc extends Money{

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

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

  public boolean equals(Object object){
    Money money = (Money) object;
    return amount == money.amount;
  }
}
  • Franc.equals는 Money.equals와 거의 똑같다. 이 두 부분을 완전히 똑같이 만들 수 있다면 Franc의 equals을 지울 수 있겠다.
  • Franc도 Dollar와 같이 똑같이 수정한다.

  • 위와 같이 수정한 후에 테스트 결과.

우리는

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

7. 사과와 오렌지

Franc와 Dollar 비교하기 (사과와 오렌지)

  @Test
  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)));
  }
  • assertFalse(new Franc(5).equals(new Dollar(5))) 이 assertion을 추가해보자.
  • 위의 equals 메서드는 amount의 값이 같은지를 비교하는 메서드지, 객체를 비교하고 있지는 않다. 즉 추가한 assertion은 실패한다.
public class Money {
  protected int amount;

  public boolean equals(Object object){
    Money money = (Money) object;
    return amount == money.amount && getClass().equals(money.getClass());
  }
}
  • 리턴 값에 두 클래스가 같은지 비교하는 로직을 추가했다.
  • 테스트는 성공한다.

우리는

  • 우릴 괴롭히던 결함 (달러와 프랑이 같음)을 끄집어내서 테스트에 담아냈다.
  • 완벽하진 않았지만 그럭저럭 봐줄 만한 방법 (getClass())로 테스트를 통과하게 만들었다.
  • 더 많은 동기가 있기 전에는 더 많은 설계를 도입하지 않기로 했다.

8. 객체 만들기

Dollar, Franc 중복

public class Dollar extends Money {

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

public class Franc extends Money{

  Franc(int amount){
    this.amount = amount;
  }
  Money times(int multiplier){
    return new Franc(amount * multiplier);
  }
}
  • 두 객체의 times함수는 로직상을 똑같다. 일단 메서드 타입을 Money로 만들어보자
  • Money의 두 하위 클래스는 그다지 많은 일을 하고 있지 않는다. 그래서 Money에 Dollar나 Franc 등을 반환하는 팩토리 메서드를 사용하여 하위 클래스를 직접적으로 참조하는 것을 줄일 것이다.

구현

@Test
  public void testMultiplication(){
    Dollar five = Money.dollar(5);
    assertEquals(new Dollar(10), five.times(2));
    assertEquals(new Dollar(15), five.times(3));
  }
  • 위와 같은 테스트를 작성한다. Money 객체에는 dollar라는 스태틱 메서드가 없으므로 빨강 막대 상태
  static Dollar dollar(int amount){
    return new Dollar(amount);
  }
  
  • dollar라는 스태틱 메서드 추가
@Test
  public void testMultiplication(){
    Money five = Money.dollar(5);
    assertEquals(new Dollar(10), five.times(2));
    assertEquals(new Dollar(15), five.times(3));
  }
  • 타입을 Dollar에서 Money로 바꾸면 여전히 빨강 막대 상태다. Money에 times라는 메서드가 없기 때문이다.
abstract class Money {
   ...
  abstract Money times(int multiplier);

}
  • 일단 Money를 추상 클래스로 변경한 후 Money.times()를 선언하자.
  static Money dollar(int amount){
    return new Dollar(amount);
  }
  • 그리고 dollar를 만드는 팩토리 메서드의 타입도 바꿔보자.
  • 모든 테스트가 잘 실행된다.
  @Test
  public void testMultiplication(){
    Money five = Money.dollar(5);
    assertEquals(Money.dollar(10), five.times(2));
    assertEquals(Money.dollar(15), five.times(3));
  }


  @Test
  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)));
  }
  • 팩토리 메서드를 테스트 코드에 적용해보자.
  @Test
  public void testFrancMultiplication(){
    Money five = Money.franc(10);
    assertEquals(Money.franc(10), five.times(2));
    assertEquals(Money.franc(15), five.times(2));
  }

  @Test
  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)));
  }
  static Money franc(int amount){
    return new Franc(amount);
  }
  • Mone에 Franc을 만드는 팩토리 메서드를 만들고 똑같이 테스트에 적용했다.

우리는

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

9.우리가 사는 시간

  • 불필요한 하위 클래스를 제거하기 위해 통화 개념을 도입해보자.

통화

@Test
  public void testCurrency(){
    assertEquals("USD", Money.dollar(1).currency());
    assertEquals("CHF", Money.franc(1).currency());
  }
  • 테스트
  abstract String currency();

  String currency(){
    return "USD";
  }
  String currency(){
    return "CHF";
  }
  • Money에 currency 메서드를 선언하고, 두 하위 클래스에서 이를 구현해보자
public class Franc extends Money{
  private String currency;

  Franc(int amount){
    this.amount = amount;
    this.currency = "CHF";
  }
  Money times(int multiplier){
    return new Franc(amount * multiplier);
  }

  String currency(){
    return currency;
  }
}

public class Dollar extends Money {
  private String currency;

  Dollar(int amount) {
    this.amount = amount;
    this.currency = "USD";
  }
  Money times(int multiplier) {
    return new Dollar(amount * multiplier);
  }

  String currency(){
    return currency;
  }
}
  • 그 다음에 통화를 인스턴스 변수에 저장하고, 메서드에서는 그냥 반환하게 끔 수정한다.
abstract class Money {
  protected String currency;
    String currency(){
    return currency;
  }
 }
  • 두 객체의 currency 메서드는 동일한 로직이므로 이를 Money 객체에 옮긴다.

통화 단위를 스태틱 팩토리 메서드에 옮기기

  Dollar(int amount, String currency) {
    this.amount = amount;
    this.currency = "USD";
  }
  
  Franc(int amount, String currency){
    this.amount = amount;
    this.currency = "CHF";
  }
  • 생성자에 currency라는 인자를 추가해준다.
  • 그러면 생성자를 호출하는 코드 두 곳이 깨진다. (팩토리 메서드, times 함수)
  Money times(int multiplier) {
    return Money.dollar(amount * multiplier);
  }

  Money times(int multiplier){
    return Money.franc(amount * multiplier);
  }
  • 먼저 times 함수의 리턴 값을 생성자 호출 방식에서 팩토리 메서드로 반환하게 수정했다.
  static Money dollar(int amount){
    return new Dollar(amount, "USD");
  }
  static Money franc(int amount){
    return new Franc(amount, "CHF");
  }
  • 팩토리 메서드 부분에서 생성자를 호출할 때 화폐 단위를 넘겨주도록 수정한다.
  Dollar(int amount, String currency) {
    this.amount = amount;
    this.currency = currency;
  }
  
  Franc(int amount, String currency) {
    this.amount = amount;
    this.currency = currency;
  }
  • 그러면 인자를 인스턴스 변수에 할당할 수 있다.
  Money(int amount, String currency){
    this.amount = amount;
    this.currency = currency;
  }
  
  Dollar(int amount, String currency) {
    super(amount, currency);
  }
  Franc(int amount, String currency) {
    super(amount, currency);
  }
  • 동일한 인자로 동일한 필드를 초기화 해주므로, 상위 객체에 생성자를 구현하고 하위 클래스에 super 클래스로 받게 수정했다.

보폭

  • 나같은 경우에는 저 글을 쓰면서 좀 답답했는데, 저자는 답답하다고 느끼면 보폭을 조금 넓히고, 보폭을 너무 많이 밟는다고 생각하면 조금 줄이라고 적었따.

우리는

  • 다른 부분들을 호출자(팩토리 메서드)로 옮기면서 두 생성자를 일치시켰다.
  • times()가 팩토리 메서드를 사용하도록 만들기 위해 리팩토링을 중단했다.
  • 비슷한 리팩토링을 한번에 큰 단계로 처리했다.
  • 동일한 생성자들을 상위 클래스로 옮겼다.

10. 흥미로운 시간

공용 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 Franc(amount* multiplier, currency);
  }
  
  Money times(int multiplier) {
    return new Dollar(amount* multiplier, currency);
  }
  • 일단 팩토리 메서드를 인라인 시키고 다시 생성자를 반환 값으로 주게 수정한다.
  • 여기서 한 가지 의문점이 생긴다. times 메서드가 Franc 객체를 반환하는게 맞는가? Money 객체를 가질 수도 있지 않을까?
  Money times(int multiplier){
    return new Money(amount* multiplier, currency);
  }
  
  class Money {
    Money times(int multiplier){
      return null;
	 }
     
    public String toString(){
      return amount + " " + currency;
    }
  }
  • Money 객체를 반환하게 수정하고, 추상 클래스였던 Money를 다시 콘크리트 클래스로 변환시켰다.
  • 에러 로그를 보기 위해 toString을 정의했다. 저자는 어차피 빨간 막대인 상태라 이 상태에서 테스트를 작성하는 것이 좋지 않다는 등 테스트도 없이 메서드를 정의하는 이유를 책에다 적어 놓았다.
  • 여전히 에러로그는 발생한다. (책에서는 Franc 테스트에서 10 CHF, 10 CHF라 뜨고 Money 객체로 나온다는데, 코드를 돌려보면 10, null이 나옴. times에서 currency 필드에 대해 초기화를 하지 않기 때문)
  public boolean equals(Object object){
    Money money = (Money) object;
    return amount == money.amount && getClass().equals(money.getClass());
  }
  • 우리는 화폐가 동일한 지를 알기 위해 클래스를 비교하는 방식으로 테스트를 진행했다.
  Money times(int multiplier){
    return new Franc(amount* multiplier, currency);
  }
  • 먼저 times의 리턴을 Money에서 Franc로 돌렸다. 다시 초록 막대로 돌아왔다.
  @Test
  public void testDiffrentClassEquality(){
    assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF")));
  }
  • 우리는 Franc(10, "CHF") == Money(10, "CHF") 이길 바랬지만 사실은 그렇지 않다고 보고된 것이다. 따라서 이러한 테스트를 구현할 수 있다.
  • 예상대로 실패한다. equals 메서드는 클래스가 아닌 currency를 비교하게 해야한다.
  public boolean equals(Object object){
    Money money = (Money) object;
    return amount == money.amount && currency().equals(money.currency);
  }
  • equals 메서드 수정 (currency().equals(money.currency);)
  • 테스트는 성공한다.
  Money times(int multiplier) {
    return new Money(amount* multiplier, currency);
  }

  Money times(int multiplier) {
    return new Money(amount* multiplier, currency);
  }
  • 리턴 값을 Money로 바꿔줘도 위의 테스트는 성공한다.
  Money times(int multiplier) {
    return new Money(amount* multiplier, currency);
  }
  • 둘 다 Money를 똑같은 코드가 되었으므로 상위 클래스에 정의한다.

우리는

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

0개의 댓글