본문 바로가기
Always Awake/TDD

<테스트 주도 개발 - 켄트백> 1~4장 정리

by 욕심많은알파카 2020. 9. 25.

본 글은 켄트 백의 <테스트 주도 개발>을 읽고 개인적으로 정리한 내용입니다. 내용에 오류가 있을 시 지적해주시면 감사하겠습니다.

저자의 글

 

테스트 주도 개발의 2원칙

  • 오직 자동화된 테스트가 실패할 경우에만 새로운 코드를 작성한다.
  • 중복을 제거한다.

 

이 규칙에 의거하여 행동 패턴이 만들어지고, 다음과 같은 프로그래밍 순서가 만들어진다.

  • 빨강 - 실패하는 작은 테스트를 작성한다. 컴파일이 되지 않아도 좋다.
  • 초록 - 빨리 테스트가 통과하게끔 만든다. 이 과정에서 코드 복붙, 테스트 통과를 위해 함수가 무조건 상수를 반환하기 등의 꼼수를 써도 상관없다.
  • 리팩토링 - 일단 테스트를 통과하게만 하는 와중에 생겨난 모든 중복을 제거한다.

 


 

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

 

  • 다중통화를 지원하는 보고서를 생성하려면, 기존의 금액체계에 통화 단위를 추가하고, 환율을 명시해야 한다.

    예를들어, $5 + 10CHF = $10(환율이 2:1일 경우)

    따라서 $5 * 2 =$10

  • 이 과정에서 해야할 일은 두 가지가 나온다.

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

 

보아하니 첫번째 테스트는 좀 복잡해보인다. 가벼운 일부터 먼저 처리하자. 두번째 테스트부터 작성한다.

 

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

이 코드는 지금 '빨강' 상태이다. public 필드이기도 하고, 금액 계산에 정수형을 사용하고 있다. 일단 '초록'을 보기 위해 문제사항들을 다 적어놓고 진행한다.

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

 

컴파일이 되게 만드려면 다음과 같은 컴파일에러를 해결해야한다.

  1. Dollar 클래스가 없음
  2. 생성자가 없음
  3. times(int) 메서드가 없음
  4. amount 필드가 없음

 

일단 컴파일이 되게 하기 위해서, 위의 구현들을 정말 '최소한'으로 한다.(정말 아무 로직도 안짜도 된다)

 

class Dollar // #1
Dollar(int amount) // #2
void times(int multiplier) // #3
int amount; // #4

 

컴파일은 되었지만, amount의 예상액이 10이 나와야 assertEquals를 통과하는데, amount값이 0이라 통과하지 못한다. 따라서

 

int amount=10;

 

amount를 그냥 10으로 바꿔준다.(...??)

어찌됐든 결과가 앞으로 이렇게 나와야 하니, 껍데기만 작성 해두자. 그럼 테스트를 통과했다!

 

그렇다면 이제 중복을 제거한다. 위의 예제에서는 중복이 잘 보이지 않는것 같지만, 사실은 다음과 같은 중복이 있음을 알 수 있다.

int amount = 5 * 2; // new Dollar(5)와 five.times(2)에서 나왔던 5와 2가 중복하여 등장하고 있다.

따라서 다음과 같이 리팩토링하여 중복을 제거한다.

int amount;

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

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

 

그럼 우리는 적어도 다섯가지 문제사항 중 두번째는 해결한 것이다.

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

 


2장 타락한 객체

 

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

이를 위해서 먼저 '작동하는' 부분에 집중하고, 그 다음에 '깔끔한' 부분을 해결한다.

 

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

 

다섯가지 문제 사항 중 4번에 집중해보자.

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

 

달러를 multiple하는 테스트를 작성했다고 쳤을 때, five.times(2) 를 거치면 Dollar five의 amount는 10으로 바뀐다. 이 상태로 assertEquals를 한번 실행하고, 다음번에 다시 five.times(3) 을 거치면 당연히 five.amount 는 10*3 = 30이 될 것이다. 테스트를 통과하지 못하는 것이다.

따라서 테스트용으로 사용할 five라는 Dollar 객체의 값은 5로 고정되어있어야한다. 그러므로 five를 오염시키지 않기 위해서, 우리는 times() 함수가 새로운 객체를 반환하여야 한다는 것을 유추해낼 수 있다.

따라서 테스트를 다음과 같이 수정한다.

 

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() 를 수정하지 않으면 새 테스트는 당연히 수정조차 되지 않을 것이다.

 

Dollar times(int multiplier) {
  amount *= multiplier;
  return null; // 테스트가 돌아가도록 하기 위한 정말 최소한의 컴파일만 수행(초록 막대)
}

 

return 값을 올바르게 고치면, 다음과 같다.

 

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

 

[테스트 코드의 껍데기를 짜고(빨강)] -> [컴파일만 되도록 고치고(초록)] -> [중복을 제거하며 올바른 값을 내뱉도록 바꾸고(리팩토링)]의 과정이 너무 잘게 쪼개어져 있어서 넘어가기 쉽지만, 이 과정들을 쪼개어 (최소한 머릿속으로) 순차적으로 처리할 수 있는 능력이 저자가 요구하는 TDD 라는 생각이 든다.

초록을 보기 위해 취할 수 있는 전략 세 가지 중 두 가지는 다음과 같다.

  • 가짜로 구현하기 : 상수를 반환하게 만들고 진짜 코드를 얻을 때까지 단계적으로 상수를 변수로 바꾸어 나간다.
  • 명백한 구현 사용하기 : 실제 구현을 입력한다.

뭘 입력해야할 지 명확히 알고, 테스트코드도 정확하게 짜고 있다면 후자를 지속한다. 그러다 예상치 못한 '빨강'을 만나면 전자를 사용하여 올바른 코드로 리팩토링한다. 이 두 가지 방법을 때에 맞추어 스왑해가며 사용하자.

 


3장 모두를 위한 평등

 

Dollar 객체와 같이 객체를 값처럼 쓸 수 있는데, 이를 값 객체 패턴(value object pattern) 이라고 한다. 값 객체는 생성자를 통해 설정된 이후에는 절대 변하지 않는다. 따라서 값 객체는 그 값이 영원히 하나의 값임을 보장받을 수 있다. 누군가가 새로운 값을 원한다면 새로운 객체를 하나 더 만들어야 할 것이다.

 

값 객체를 사용했을 때 기억 할 점은 2장에서 다루었던 것과 마찬가지로, 모든 연산이 새 객체를 반환해야 한다는 점과, equals() 를 구현해야 한다는 점이다. 만약 Dollar를 해시 테이블의 키로 쓸 것이라면 equals() 를 구현할 때 hashCode() 도 만들어야 하지만, 일단 그것은 나중에 생각하기로 한다.

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

 

public void testEquality(){
  assertTrue(new Dollar(5).equals(new Dollar(5)));
}

equals() 가 없으니 한번 구현해보자.

public boolean equals(Object object){
  return true; // 우리 목표는 equals가 true를 반환하도록 만드는 것이므로, 정말 true만 반환시킨다(물론 나중에 리팩토링 할것이다)
}

결국 equals() 가 반환하는 true는 다음을 의미한다는 것을 모두 알 수 있다.

'5 == 5'

'amount == 5'

'amount == dollar.amount'

 

 

삼각측량법

 

삼각 측량법

라디오 신호를 두 수신국이 감지하고 있을 때, 두 수신국 사이의 거리가 알려져있고, 각 수신국이 신호의 방향을 알고 있다면 그것만으로 충분히 신호의 거리와 방향을 알 수 있다. 이것을 삼각측량이라고 이야기한다.

테스트 코드에서 삼각 측량법은 예제가 두 개 이 상 있어야 일반화할 수 있으므로, 새로운 예제를 만들어 보자.

$5 = $6

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

 

이제 equals를 리팩토링하여 동치성(equality)를 테스트하는 일반화 테스트 코드로 바꾼다. 같은 타입의 객체로 바꾸어 동등한 입장에서 비교한다.

 

public boolean equals(Object object){
  Dollar dollar = (Dollar) object;
  return amount = dollar.amount;
}

 

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

 

times() 를 리팩토링 할 때도 삼각측량을 사용할 수 있다. 예제가 $5 * 2 = $10, $5 * 3 = $15 였다면 단순히 상수를 리턴해서는 테스트를 통과할 수 없었을 것이다. 따라서 두 예제를 모두 해결하는 '일반화 된 모델'을 짤 수 밖에 없게 만든다.

저자가 이야기하는 삼각측량은 이런 느낌인 듯 하다. 삼각측량에서 두 개의 수신국 정보를 가지고 특정 신호의 방향성을 찾아 내듯이, 두 개(혹은 N개)의 예제를 이용하여 특정 테스트코드의 방향성(즉 일반화)을 이끌어내는 방법 이라는 생각이 들었다.

삼각 측량은 두 예제에 대하여 테스트 코드를 하나씩, 총 두 번 짜는 기법이므로 어떻게 생각하면 비효율적인 방식이다(다른 방식에 비해서). 그럼에도 불구하고 삼각측량을 사용해야 하는 경우는 어떻게 리팩토링(또는 설계)해야 할 지 전혀 모를 때 인데, 이 때 삼각 측량은 현재 프로그램이 지원해야 할 '변화 가능성', 즉 방향 을 세우는 데에 도움을 준다. 여러 예제에 맞추어 몇 몇 부분을 변경하다 보면 어떤 사항들을 다 커버해야 할 것인지 감이 올 것이다.

 

값 객체 두개를 비교하도록 하는 동일성 문제 는 해결되었지만, 널 값이나 다른 객체들과 비교할 때에는 equals() 함수에 보충할 부분이 아직 있을 수 있다. 일단은 할일 목록에 적어두기만 하자.

 

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

 

3장에서 다룬 내용은 다음과 같다.

  • 값 객체를 사용한다면 새로운 오퍼레이션을 구현해야한다( 여기서는 equals())
  • 해당 오퍼레이션을 테스트하고, 간단히 구현한다.
  • 삼각 측량법에 따라 두 경우를 모두 수용할 수 있을 정도로 리팩토링한다.

 


4장 프라이버시

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

 

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

 

Dollar.times() 연산은 호출받은 Dollar 객체 값에 인자로 받은 곱수만큼 곱한 값을 갖는 Dollar를 반환해야 한다. 그럼 assertEquals() 에서 불필요한 부분을 리팩토링 해볼 수 있다.

 

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

 

불필요한 product 변수를 걷어냈으므로, Dollar의 amount 변수를 public에서 사용하지 않아도 되게 되었다. 따라서, amount 변수를 private으로 선언할 수 있다.

 

private int amount;

 

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

 

한 가지를 해결했지만, 주의할 점이 있다. 3장에서 만들었던 equals() 라는 동치성 테스트가 제대로 작동하지 않는다면, 새로 조정한 테스트코드가 정확하게 작동한다는 것을 보장할 수 없다는 것이다. equals가 두 값 객체를 올바르게 비교한다는 가정 하에 10product.amount 를 걷어낸 것이기 때문이다.

이번 장에서 배운것은 다음과 같다.

  • 개발된 기능은 테스트의 개선을 위해서만 사용했다.( Dollar.times() )
  • 두 테스트가 동시에 실패하면, 망한다.( equals() 가 제대로 안 만들어졌다면 망할 것이다)
  • 위험요소가 있음에도 계속 진행한다.(그럼에도 불구하고 테스트코드를 짰다면 코드는 전진한다)
  • 테스트와 코드 사이에 결합도를 낮추기 위해, 테스트하는 객체의 새 기능을 사용했다. -> 테스트코드에서 amount 를 사용했기 때문에 amount 를 private 변수로 만들 수 없다면, 테스트와 코드가 너무 결합된 것이다. 따라서 Dollar.times() 의 새 기능(값 객체를 반환)을 활용하여 결합도를 낮추었다.

댓글