TDD(Test-Driven-Development) By Example - 1부

Jeongmin Yeo (Ethan)·2021년 4월 4일
3
post-thumbnail

TDD(Test-Driven-Development) 1부. Money 예제에 대해 정리합니다.

학습할 내용은 다음과 같습니다.

  • Intro
  • 1장. 다중 통화를 지원하는 Money 객체
  • 2장. 타락한 객체
  • 3장. 모두를 위한 평등
  • 4장. 프라이버시
  • 5장. 솔직히 말하자면
  • 6장. 돌아온 '모두를 위한 평등'
  • 7장. 사과와 오렌지
  • 8장. 객체 만들기
  • 9장. 우리가 사는 시간
  • 10장. 흥미로운 시간
  • 11장. 모든 악의 근원
  • 12장. 드디어, 더하기
  • 13장. 진짜로 만들기
  • 14장. 바꾸기
  • 15장. 서로 다른 통화 더하기
  • 16장. 드디어, 추상화
  • 17장. Money 회고

Reference: Test-Driven Development: By Example

Intro

1부에서는 완전히 테스트에 의해 주도되는 전형적 모델 코드를 개발할 것이다.

이를 통해 테스트 주도 개발(TDD)의 리듬을 보도록 하는 것이 목표다. 그 리듬은 다음과 같이 요약할 수 있다.

  1. 재빨리 테스트를 하나 추가한다.
  2. 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
  3. 코드를 조금 바꾼다.
  4. 모든 테스트를 실행하고 전부 성공하는지 확인한다.
  5. 리팩토링을 통해 중복을 제거한다.

이를 통해 다음의 것들을 확인할 수 있어 놀랄 것이다.

  1. 각각의 테스트가 작은 기능의 추가를 어떻게 커버할 수 있는지
  2. 새 테스트를 돌아가게 하기 위해서 얼마나 작고 못생긴 변화가 가능한지
  3. 얼마나 자주 테스트를 실행하게 되는지
  4. 얼마나 수 없이 작은 단계를 통해 리팩토링을 이어가게 되는지

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

다중 통화를 지원하는 Money 객체부터 시작해보자.

다음과 같은 보고서가 있다.

종목가격합계
GE40010040000
IBM10002525000

다중 통화를 지원하는 보고서를 만들려면 통화 단위를 추가해야 한다.

종목가격합계
Norvartis400150 CHF60000 CHF
IBM100025 USD25000 USD

또한 환율도 명시해야 한다.

기준변환환율
CHFUSD1.5

새로운 보고서를 생성하려면 어떤 기능들이 있어야 할까?

어떤 테스트들이 있어야 보고서에 제대로 계산 되도록 하는 코드가 완성됐다는 걸 확신할 수 있을까?

앞으로 어떤 일을 행햐하고 지금 하는 일에 집중할 수 있도록 도와주며 언제 일이 끝나는지 알려줄 수 있게끔 할 일 목록을 다음고 같이 작성해보자.

  • 통화가 다른 두 금액을 더해서 주어진 환율에 맞게 변환 금액을 결과로 얻을 수 있어야 한다.
  • 어떤 금액(주가)를 어떤 수(주식 수)에 곱한 금액을 결과로 얻을 수 있어야 한다.

할 일 목록에서 볼 수 있듯이 두번째 항목을 먼저 다룬다고 생각해보자. 어떤 객체가 필요할까?

여기서 부터 문제다 중요한건 어떤 객체가 필요한 게 아니라 테스트를 먼저 만들어야 한다. 항상 이 사실을 되뇌이자.

어떤 테스트가 필요할지 생각해보자. 첫번째 목록보다 두번째 목록이 좀 더 간단해 보이므로 작은 것부터 시작해보자.

테스트를 작성할 때는 오퍼레이션의 완벽한 인터페이스에대해 상상해보는 것이 좋다

다음은 간단한 곱셈의 예이다.

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

분명 지금 이 곱셈 테스트는 문제가 많다. 금액을 계산하는 데 정수형을 사용한다던지.

하지만 중요한건 작은 단계로 시작한다는 사실이다. 이런 문제점들을 적어놓고 계속 진행하는게 중요하다.

지금 우리에겐 실패하는 테스트가 주어진 단계이고 가장 빠르게 테스트를 성공하는게 목표다.

이를 위해서 가짜 구현(상수를 리턴하도록 하는 방법)으로 시작하는 것도 좋다. 다만 어느 부분을 다시 손봐야 하는지 메모해두자.

이 예에서 객체를 만들지도 않았으므로 컴파일조차 되지 않을 것이다. 지금 우리의 목표는 테스트를 성공하는게 목표이므로 다음과 같은 컴파일 에러를 해결하자.

  • Dollar 클래스 없음
  • Dollar 생성자 없음
  • times(int) 메소드 없음
  • Dollar.amount 필드 없음

이 문제들을 해결시키고 나고 테스트를 통과시킨 후 다음에 할 것은 나머지 테스트들도 통과시키는 것이다.

이전의 테스트들을 통과시키면서 범위도 줄었을 것이다. 작은 단계들을 하나씩 해결했으니.

다음 테스트 통과로 넘어가지전에 주기를 만들어 놓고 넘어가면 된다.

테스트 주기를 만드는 이유는 방금 우리가 작성한 코드는 테스트를 통과시키기 위해 급하게 만든 코드이므로 냄새가 고약해서이다. 그러므로 이 문제를 해결하고 넘어가야 한다.

주기는 다음과 같다.

  1. 작은 테스트를 하나 추가한다.
  2. 모든 테스트를 실행해서 테스트가 실패하는 것을 확인한다.
  3. 조금 수정한다.
  4. 모든 테스트를 실행해서 테스트가 성공하는 것을 확인한다.
  5. 중복을 제거하기 위해 리팩토링을 한다.

2장. 타락한 객체

일반적인 TDD 주기는 다음과 같다.

  1. 테스트를 작성한다. 마음속에 있는 오퍼레이션이 코드에 어떤 식으로 나타나길 원하는지 생각해보자. 원하는 인터페이스를 작성하고 올바른 답을 얻기 위한 이야기를 써내려 가면 된다.
  2. 실행 가능하게 만든다. 다른 무엇보다도 중요한 건 빨리 테스트를 성공시키는 것이다. 깔끔하고 단순한 해법이 보인다면 바로 그걸 입력하라. 오래 걸릴 문제는 최대한 단순한 해법을 입력하고 적어놓고 테스트를 성공 시키기 위해 다음 문제를 찾아 나서자.
  3. 올바르게 만든다. 이제 시스템이 작동하므로 직전에 저질렀던 죄악을 수습하자.

궁극적인 목적은 작동하는 깔끔한 코드를 얻는 것이다.

작동하는 깔끔한 코드를 얻는 것은 최고의 프로그래머도 가끔 도달하기 어려운 목표이며 우리 같은 평범한 프로그래머는 불가능하다.

그러므로 나누어서 정복하자.

일단 ''작동하는''에 집중을 해서 이 부분을 먼저 해결하자. 그 다음 깔끔한 코드를 해결하자.

이는 깔끔한 코드를 먼저 해결한 후에 '작동하는' 부분을 해결해가는 방향인 아키텍처 주도 개발과 정반대다.

다음은 테스트를 통과시킨 후 Dollar에 부작용이 생긴 경우를 해결해보자.

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

times()를 처음 호출한 후 five 객체는 더이상 5가 아니다.

그러므로 times() 메소드에서 새로운 객체를 반환하도록 해서 문제를 해결하면 어떨까? 그렇게 하면 원래의 5가 변하지 않을 것이다.

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

이렇게 구현을 바꾼 후 이전에 작성한 테스트를 다시 돌려보면 된다.

1장에서는 테스트를 통과하기 위해 일단 가짜 구현으로 시작해서 실제 구현을 만들어 갔다면 이번 장에서는 올바른 구현이라고 생각했던 것들을 고치고 다시 이전 테스트 케이스를 활용해서 테스트를 성공시켰다.

다음은 최대한 빨리 테스트를 성공 시키기 위해 취할 수 있는 3가지 전략 중 두 가지다.

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

보통 실무에서 TDD를 사용할 때 두 방법을 번갈아가며 사용하는 걸 추천한다.

모든 일이 자연스럽게 잘 진행되고 내가 뭘 입력해야 할지 알 때는 명백한 구현을 계속 더해 나가면 된다. 다만 이렇게 나에게 명백한 사실을 컴퓨터에게도 명백한 사실임을 확인하기 위해서 각각의 명백한 구현에 대한 테스트를 작성하고 실행하자.

그러던 중 예상치 못하게 테스트가 실패한다면 가짜로 구현하기를 통해 한발 물러선 후 어디가 문제인지를 파악하고 다시 명백한 구현을 사용하면서 해결해 나가면 된다.


3장. 모두를 위한 평등

지금의 Dollar 객체 같이 객체를 값처럼 쓸 수 있는데 이것을 값 객체 패턴 (Value Object Pattern) 이라고 한다.

값 객체 패턴에 대한 제약사항 중 하나는 객체의 인스턴스 변수가 생성자를 통해서 일단 설정된 후에는 결코 변하지 않는다는 것이다.

값 객체 패턴이 암시하는 것 중 하나는 2장에서와 같이 모든 연산은 새 객체를 반환해야 한다는 점이다.

또 다른 암시는 객체는 equals() 메소드를 구현해야 한다는 것이다. 만약 Dollar를 HashMap() 같은 해시 테이블의 키로 쓸 생각이라면 hashCode() 메소드를 같이 구현해야 한다.

equals() 메소드의 테스트 코드는 다음과 같이 구현할 수 있다.

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

그리고 실제 구현은 다음과 같이 작성할 수 있다.

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

4장. 프라이버시

이제 동치성 문제를 정의했으므로 이를 이용해 테스트가 조금 더 많은 이야기를 해줄 수 있도록 만들자.

개념적으로 Dollar.times() 메소드는 연산을 호출 받은 객체의 값에 인자로 받은 값만큼 곱한 값을 갖는 Dollar 객체를 반환한다.

하지만 테스트가 정확히 그것을 말하진 않는다.

다음과 같이 testMultiplication 테스트를 Dollar와 Dollar를 비교하는 것으로 바꾸자.

public void testMuliplication(){
  Dollar five = new Dollar(5);
  assertEquals(new Dollar(10), five.times(2)); // 변경된 부분 1
  assertEquals(new Dollar(15), five.times(3)); // 변경된 부분 2
} 

이렇게 좀 더 이전에 고친 코드와 맞도록 테스트 코드를 고쳤다.

테스트를 고치고 나니 이제 Dollar의 amount 인스턴스 변수는 private으로 바꿀 수 있다.

여기서 짚고 넘어갈 부분은 만약 equality 테스트 코드가 정확히 작동한다는 검증을 하지 않았었다면 이 테스트 코드 역시 정확히 작동하는지도 검증이 어려워진다.

이것은 TDD를 하면서 적극적으로 관리해야 할 위험 요소다.

위험을 낮추기 위해서 코드와 테스트를 통해 표현할 뿐이다. 이를 통해 결함의 정도를 낮추기를 희망할 뿐이다.


5장. 솔직히 말하자면

첫번째 할 일 목록이였던 통화가 다른 두 금액을 더해서 주어진 환율에 맞게 변환 금액을 결과로 얻을 수 있어야 한다. 의 테스트에는 어떤 식으로 접근하는 게 가장 좋을까?

너무 큰 발걸음인 것 같다.

우선 작은 발걸음부터 시작해보자면 Dollar 객체와 유사한 Franc 을 표현할 수 있는 객체가 필요한 것 같다.

Franc 객체를 만들고 이전 처럼 테스트 케이스를 작성해보자.

public void testFrancMultiplication(){
  Franc five = new Franc(5);
  assertEquals(new Franc(10), five.times(2));
  assertEquals(new Franc(15), five.times(3));
}

4장에서 만든 테스트와 유사하지 않는가?

테스트 작성 주기에서 최대한 빠르게 테스트를 작성하라는 말에 따라서 이전의 테스트를 재활용해서 테스트를 만들었다.

하지만 이렇게 함으로써 FrancDollar 객체 사이에는 중복이 발생한 것이다.

이전 테스트 코드 주기에서 중복을 제거하라는 말을 기억하는가?

FrancDollar 사이에는 중복이 엄청 많기 때문에 다음 테스트를 작성하기 전에 중복들을 제거해야 한다.


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

작가 윌러스 스테그너(Wallace Stegner)가 'Grossing to Safety'에서 등장인물의 작업장을 묘사하는 부분은 정말 멋들어졌다.

모든 것이 완벽히 제자리에 있고 바닥에는 티 한점 없으며, 질서와 깨끗함 그 자체다. 그러나 그 등장인물은 아무것도 만들지 못한다.

왜냐하면 그에겐 준비가 삶의 작업이였기 떄문이다.

우리는 5장에서 이 함정을 피했다. 실제로 새로운 테스트 케이스를 하나 작동하게 만들었지만 테스트를 빨리 통과하게 하기 위해서

계속해서 코드를 복사해서 붙이는 죄를 저질렀다.

이제 청소할 시간이다.

가능한 방법 한 가지는 우리가 만든 클래스에 중 하나가 다른 클래스를 상속받게 하는 것이다. 하지만 이 방법은 어떤 코드도 구원하지 못한다.

또 다른 방법은 두 클래스의 공통 상위 클래스를 찾아내는 방법이다. 공통 상위 클래스인 Money 클래스를 둬서 공통의 메소드 equals() 를 두는 방법이다.

class Money {
  protected int amount; 
	
  public boolean equals(Object object){
  	Money money = (Money) object;
  	return amount == money.amount; 
	}
}

class Dollar extends Money {
 
}

이렇게 Dollar 클래스의 equals() 메소드를 Money 클래스로 옮기고 다시 테스트를 실행시켜 본다.

테스트가 성공적으로 돌아간다면 이제는 Franc 클래스의 equals 메소드를 제거해야 한다.

Franc.equlas() 와 Money.equals() 사이의 관계는 Dollar.equals() 와 Money.equals() 사이와 다른 점이 없으므로 똑같이 코드의 중복을 제거하자. 그리고 테스트를 돌려보자.

지금까지 한 작업을 검토해보면 우리는

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

7장. 사과와 오렌지

6장이 끝날 무렵 떠오른 생각에 대해 얘기해보자. FrancDollar 를 비교하면 어떻게 될까?

다음 테스트 케이스를 보자.

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

마지막 문장의 assertion 은 실패한다. 당연히 Franc과 Dollar는 다르다.

다음과 같이 equality() 메소드를 고쳐서 동일한 클래스에서 동일한 값이여야만 같다로 수정해줘야 한다.

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

모델 코드에서 클래스를 이런 식으로 사용하는 건 좀 지저분해보인다. 자바 객체의 용어를 사용하는 것보다 재정 분야에 맞는 용어를 사용하면 좋아보인다.

하지민 현재는 통화(currency) 개념 같은 게 없고, 통화 개념을 도입할 충분한 이유가 없어 보이므로 체크만 해두고 잠시 이대로 두자.

이제는 공통 times() 메소드 코드를 처리해야 할 때다. 따라서 혼합된 통화 간의 연산에 대해 다루어야 한다.


8장. 객체 만들기

두 times() 메소드 구현 코드가 거의 똑같다.

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

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

양쪽 모두 Money를 반환하게 만들면 더욱 유사하게 만들 수 있다.

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

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

Money 의 두 하위 클래스는 그다지 많은 일을 하지 않으므로 없애는게 좋아 보이지만 그러면 너무 많은 변화를 겪으므로 TDD에는 적합하지 않다.

그렇다면 어떻게 해결할까? 하위 클래스에 대한 직접적인 참조가 적어진다면 하위 클래스를 제거하기 위해 한 발짝 더 다가섰다고 할 수 있겠다.

MoneyDollar 를 반환하는 팩토리 메소드(factory method)를 도입함으로써 해결할 수 있다.

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

구현 코드은 Dollar 를 생성하여 반환할 수 있다.

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

이렇게 바꾸면 다른 테스트 코드에서 Money 에는 times() 메소드가 정의되지 않았다는 사실을 알려준다.

지금은 그걸 구현할 준비가 되어 있지 않았기 때문에 Money 를 추상 클래스로 변경한 후 Money.times()를 선언한다.

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

다음 장에서 우리는 times() 의 중복을 거둬낼 것이다.

여기서 우리가 한 것들을 검토해보자.

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

9장. 우리가 사는 시간

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

그렇다면 통화 개념을 어떻게 구현하길 워하는가? 아차 실수 했다. 통화 개념을 어떻게 테스트 하길 원하는가?

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

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

우선 Money 에 currency() 메소드를 선언하자.

// Money.class
abstract String currency(); 

그 다음 두 하위 클래스에서 이를 구현하자.

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

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

우린 두 클래스를 모두 포함할 수 있는 동일한 구현을 원한다. 통화를 인스턴스 변수에 저장하고 메소드에서는 그냥 그걸 반환하도록 만들자.

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

String currency(){
  return currency; 
}

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

String currency(){
  return currency; 
}

이제 두 currency()가 동일하므로 변수 선언과 currency() 메소드 모두 상위 클래스로 올릴 수 있다.

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

문자열 "USD"와 "CHF" 를 정적 팩토리 메소드로 옮긴다면 두 생성자는 동일해질 것이고 공통 구현을 만들 수도 있을 것이다.

우선 생성자에 인자를 추가하자.

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

이로 인해 생성자를 호출하는 코드 두 곳이 깨진다.

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

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

여기서 주목할 곳은 Franc.times() 메소드가 팩토리 메소드를 호출해서 생성하지 않고 생성자를 호출한다는 점이다.

지금 이걸 고쳐야 하나? 아니면 지금 하는 일을 끝내기전까지 기다려야 할까? 교리상으로는 기다리는게 맞지만 바로 이 일을 처리할 수 있다면 고쳐도 상관 없다.

그러므로 다음과 같이 고쳐질 수 있다.

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

지금과 같은 일을 TDD 하는 동안 계속 해주어야 할까? 조금 답답하다면 보폭을 넓혀서 성큼성큼 걸어라.

성큼성큼 걸음으로써 불안감이 생긴다면 보폭을 줄여라. TDD란 조종해 나가는 과정이다.

이제 Dollar 클래스의 경우에도 적용시키고 두 동일한 생성자를 상위 클래스로 옮기자.

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

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

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

지금까지 한 것을 검토해보자.

  • 큰 설계 아이디어를 다루다가 조금 곤경에 빠졌다 .그래서 좀 전에 주목했던 더 작은 작업을 수행했다.
  • 다른 부분들을 호출자(팩토리 메소드)로 옮김으로써 두 생성자를 일치시켰다.
  • times()가 팩토리 메소드를 사용하도록 만들기 위해 리팩토링을 잠시 중단했다.
  • 비슷한 리팩토링을 한번씩 한 단계로 처리했다.
  • 동일한 생성자들을 상위 클래스로 옮겼다.

10장. 흥미로운 시간

이 장을 끝내고 나면, Money 를 나타내기 위한 단 하나의 클래스만을 갖게 될 것이다.

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

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

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

이 뚤을 동일하게 만들기 위한 명백한 방법이 없다. 때로는 전진하기 위해 물러서야 할 때도 있는 법이다.

팩토리 메소드를 인라인 메소드로 변환시키면 어떨까? (바로 전 장에서 팩토리 메소드를 호출하도록 바꿨었다. 물론 실망스러운 일이다.)

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

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

Franc 에서는 인스턴스 변수 currency가 항상 "CHF" 이므로 다음과 같이 바꿀 수 있다.

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

마찬가지로 Dollar 도 이런식으로 바꿀 수 있다.

이제 거의 다 왔다.

times() 메소드에서Franc 을 가질지 Money 를 가질지가 정말로 중요한 사실인가? Money 를 가져도 되지 않는가?

이렇게 고민할 문제에 대해 우리는 우리가 그동안 짜놓은 깔끔한 코드와 테스트 코드에 물으면 된다.

TDD를 하다보면 컴퓨터에게 물으면 되는 문제에 대해 엔지니어가 고민을 하는 경우가 있다. 가끔은 그냥 컴퓨터에게 물어보는 것도 좋다.

실험을 실행하기 위해 Franc.times() 가 Money를 반환하도록 고쳐보자.

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

컴파일러가 에러 메시지를 낸다. 더 나은 메시지를 보기 위해서 toString() 메소드를 정의하자.

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

toString() 메소드는 테스트도 없이 작성해도 된다. 왜냐하면 디버그 출력에만 쓰이기 때문이다.

문제는 equals() 메소드 구현에 있었다.

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

equals() 메소드에선 클래스 이름을 기준으로 비교하고 있었다. 하지만 정말로 검사해야 하는 부분은 currency가 같은지 여부다.

이미 테스트 케이스가 실패한 상황에서 테스트를 추가로 작성해서 골치 아픈 일을 만들고 싶지 않다.

하지만 지금은 실제 모델 코드를 수정하려고 하는 중이고 테스트 없이는 모델 코드를 수정할 수 없다.

보수적인 방법을 따르자면 변경된 코드를 되돌려서 다시 테스트 성공 상태로 돌려놓고 이 테스트를 시작해야 한다.

그러고 나서야 equals() 메소드를 위해 테스트를 고치고 구현 코드를 고칠 수 있게 된다.

보수적으로 해보자. 이전에 변경했던 사항들을 되돌려놓자.

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

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

다시 모든 테스트는 성공적으로 돌아간다.

우리 상황은 Franc(10,"CHF")Money(10, "CHF") 가 같기를 바라지만 사실은 그렇지 않다고 보고된 상황이다.

우리는 이걸 그대로 테스트에 사용할 수 있다.

public void testDifferentClassEquality(){
  assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF"))); 
}

예상대로 실패한다. equals() 메소드를 이제 고치자.

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

이제 Franc.times()에서 Money 를 반환해도 테스트가 여전히 통과하던 사실을 알 수 있다.

Dollar.times 도 마찬가지로 통과하니 이제 두 구현이 동일하다 그러므로 상위 클래스로 끌어 올릴 수 있다.

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

곱하기도 구현했으니 이제 아무것도 안 하는 하위 클래스들을 제거할 수 있겠다.

지금까지 한 일을 검토해보자.

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

11장. 모든 악의 근원

두 하위 클래스 DollarFranc 에는 달랑 생성자 밖에 없다.

단지 생성자 때문에 하위 클래스가 있을 필요는 없기 때문에 하위 클래스를 제거하는 것이 좋겠다.

코드의 의미를 변경하지 않으면서도 하위 클래스에 대한 참조를 상위 클래스에 대한 참조로 변경할 수 있다.

일단 Money.franc() 을 고쳐보자.

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

그리고 Money.dollar 도 고쳐보자.

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

이렇게 바꾼 후에 Franc 은 우리가 작성했던 테스트 코드에서 여전히 참조하고 있었다.

public void testDifferentClassEquality(){
  assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF"))); 
}

클래스 대신 currency를 비교하도록 강요하는 테스트 코드는 여러 클래스가 존재할때만 의미가 있다.

이제 클래스가 지워진 시점에서는 필요가 없어졌다. 도움이 안되는 테스트 코드다. 그러므로 Franc 클래스와 함께 같이 지워버리자.

이제 클래스는 하나 뿐이다. 덧셈을 다룰 준비가 됐다.

일단은 지금까지 한 작업을 검토해보자.

  • 하위 클래스의 속을 들어내는 걸 완료하고 하위 클래스를 삭제했다.
  • 기존의 소스 구조에서는 필요했지만 새로운 구조에서는 필요 없게 된 테스트를 제거했다.

12장. 드디어, 더하기

남은 할 일을 보자.

$5 + 10CHF = $10 (환율이 2:1 인 경우)

전체 더하기 기능에 대한 스토리를 어떻게 다 적어야 할지 잘 모르겠다.

그러므로 좀 더 간단한 예 $5 + $5 = $10 에서 시작해보자.

public void testSimpleAddition(){
  Money sum = Money.dollar(5).plus(Money.dollar(5)); 
  assertEquals(Money.dollar(10, sum)); 
}

Money.plus 같은 경우는 어떻게 구현해야 할지 명확하므로 가짜 구현을 이용하지 않고 바로 구현하겠다.

// Money.class
Money plus(Money addend){
  return new Money(amount + addend.amount, currency);
}

물론 명백하지 않으면 가짜 구현을 하고 리팩토링 하는 식으로 접근할 수 있다.

다중 통화 연산을 어떻게 표현해야 할까?

설계상 가장 어려운 제약은 다중 통화 사용에 대한 내용을 시스템의 나머지 코드에게 숨기고 싶다는 점이다.

한 가지 가능한 전략은 모든 내부 값을 참조 통화로 전환하는 것이다. 하지만 이 방식으로는 여러 환율을 쓰기 어렵다.

대신 편하게 여러 환율을 표현할 수 있으면서도 산술 연산 비슷한 표현들을 여전히 산술 연산처럼 다룰 수 있는 해법이 있으면 좋을 것 같다.

방법은 객체를 통한 것이다.

가지고 있는 객체가 우리가 원하는 방식으로 동작하지 않는 경우에 다른 새로운 객체를 만들어서 사용하는 방법이 있다.

어떻게 이런 생각을 할 수 있을까? TDD는 적절한 때에 번뜩이는 통찰을 보장하지 못한다. 그렇지만 확신을 주는 테스트와 조심스럽게 정리된 코드를 통해 통찰에 대한 준비를 할 수 있다.

해법은 Money 와 비슷하게 동작하지만 사실은 두 Money 의 합을 나태는 새로운 객체를 만드는 것이다.

이 아이디어를 설명하기 위한 몇가지 메타포가 있다. 한 가지는 Money 의 합을 마치 지갑처럼 취급하는 것이다. 한 지갑에는 금액과 통화가 서로 다른 여러 화폐들이 들어갈 수 있다.

또 다른 메타포는 2 + 3 X 5 와 같은 수식이다. 이런 연산의 결과로 Expression 들이 생겨나는데 그 결과는 Sum 이 될 것이다.

연산이 완료되면 환율을 이용해서 결과 Expression 을 단일 통화로 축약할 수 있다.

이 방식을 테스트에 적용해보자

테스트의 마지막은 다음과 같이 끝날 것이다.

public void testSimpleAddition(){
  ...
  assertEquals(Money.dollar(10), reduced); 
}

reduced 란 이름의 ExpressionExpression 에 환율을 적용함으로써 얻어진다.

실제 그럼 환율이 적용되는 곳은 어디인가? 은행이다.

그러므로 다음과 깉이 쓸 수 있다.

public void testSimpleAddition(){
  ...
  Money reduced = bank.reduce(sum, "USD"); 
  assertEquals(Money.dollar(10), reduced); 
}

Bank 가 reduce 책임을 수행하는가? 단순히 그게 제일 먼저 떠올랐다는 좋지 않다.

Expression 은 우리가 하려고 하는 일의 핵심에 해당한다. 이런 핵심이 되는 객체가 다른 부분에 대해서 될 수 있는 한 모르도록 노력해야한다.

그렇게 하면 핵심 객체가 가능한 유연하게 동작할 수 있다.

그리고 Expression 과 관련이 있는 오퍼레이션이 많을 수 있다고 상상했다. 만약에 모든 오퍼레이션을 Expression 에만 추가한다면 무한히 커질 것이다.

이런 생각들이 충분한 이유는 되지 않겠지만 당장 이렇게 진행해도 별 문제가 없다.

또한 Bank 가 별 필요 없게 된다면 reduce() 메소드를 구현할 책임을 Expression 으로 옮겨도 좋다.

이제 나머지 테스트 코드를 짜자.

public void testSimpleAddition(){
  Money five = Money.dollar(5); 
  Expression sum = five.plus(five); 
  Bank bank = new Bank(); 
  Money reduced = bank.reduce(sum, "USD"); 
  assertEquals(Money.dollar(10), reduced); 
}

이제 컴파일 에러가 나므로 이를 해결하자.

// Expression.interface
interface Expression{
}

Money.plus()Expression 을 반환해야 한다.

// Money.class
Expression plus(Money addend){
  return new Money(amount + addend.amount, currency); 
}

이건 MoneyExpression 을 구현해야 한다는 의미이므로 다음과 같이 바꾸자

class Money implements Expression

이제 Bank 클래스와 reduce() 메소드가 필요하다.

class Bank{
  Money reduce(Expression source, String to){
    return Money.dollar(10); 
  }
}

이를 통해 테스트 케이스는 성공했고 리팩토링할 준비가 됐다.

지금까지 한 것을 검토해보자.

  • 큰 테스트를 작은 테스트 ($5 + 10CHF에서 $5 + $5)로 줄여서 발전을 나타낼 수 있도록 했다.
  • 우리에게 필요한 계산(computation)에 대한 가능한 메타포들을 신중히 생각해봤다.
  • 새 메타포에 기반하여 기존의 테스트들을 재작성했다.
  • 테스트를 빠르게 컴파일 했다.
  • 그리고 테스트를 실행했다.
  • 진짜 구현을 만들기 위해 필요한 리팩토링을 기대하고 있다.

13장. 진짜로 만들기

모든 중복을 제거하기 전까지는 $5 + $5 = $10 테스트에 완료 표시를 할 수 없다.

코드 중복은 없지만 데이터 붕복은 있다. 가짜 구현에 있는 $10 은 사실 테스트 코드에 있는 $5 + $5와 같다.

// Bank.class
Money reduce(Expression source, String to){
    return Money.dollar(10); 
}
public void testSimpleAddition(){
  Money five = Money.dollar(5); 
  Expression sum = five.plus(five);  
  Bank bank = new Bank(); 
  Money reduced = bank.reduce(sum, "USD"); 
  assertEquals(Money.dollar(10), reduced); 
}

이전에 가짜 구현이 있을 때는 진짜 구현으로 거꾸로 작업해 가는 것이 명확했다. 단순히 상수를 변수로 바꾸는 일이었으므로.

하지만 지금은 이 작업이 분명하지 않다. 그러므로 순방향으로 작업해보자.

우선 할 일은 Money.plus() 를 그냥 Money 가 아닌 Expression(Sum) 을 반환해야 한다. 이를 통해 아마도 나중에 동일한 통화 단위를 더하는 특별한 경우에 대한 최적화를 할 것이다.

Money 의 합은 Sum 이다.

public void testSimpleAddition(){
  Money five = Money.dollar(5); 
  Expression result = five.plus(five); 
 	Sum sum = (Sum) result;
  assertEquals(five, sum.augend);
  assertEquals(five, sum.addend); 
}

덧셈의 첫 인자를 피가산수 augend 라고 부른다고 한다.

이 코드를 컴파일 하기 위해서는 augendaddend 필드를 가지는 Sum 클래스가 필요하다.

class Sum{
  Money augend;
  Money addend; 
}

Money.plus()Sum 이 아닌 Money 를 반환하게 되어 있기 때문에 이 코드는 ClassCastException 을 발생시킨다.

그러므로 다음과 같이 코드 변경을 해야한다.

// Money.class
Expression plus(Money addend){
  return new Sum(this, addend);
}

// Sum.class
Sum(Money augend, Money addend){
  this.augend = augend;
  this.addend = addend; 
}

// Sum.class
class Sum implements Expression

이제 Bank.reduce()Sum 을 전달받는다. 만약 Sum 이 가지고 있는 Money 통화가 모두 동일하고, reduce 를 통해 얻어내고자 하는 Money 통화 역시 같다면 결과는 Sum 내에 있는 Money 들의 amount 합을 합친 Money 객체여야 한다.

public void testReduceSum(){
  Expression sum = new Sum(Money.dollar(3), Money.dollar(4)); 
  Bank bank = new Bank(); 
  Money result = bank.reduce(sum, "USD"); 
  assertEquals(Money.dollar(7), result); 
}
// Sum.class
public Money reduce(String to){
  int amount = augend.amount + addend.amount;
  return new Money(amount, to); 
}
// Bank.class
Money reduce(Expression source, String to){
  Sum sum = (Sum) source;
  return sum.reduce(to); 
}

Bank.reduce() 의 인자로 Money 를 넘겼을 경우를 처리하자.

// Bank.class
Money reduce(Expression source, String to){
  if(source instanceof Money) return (Money) source;
  Sum sum = (Sum) source;
  return sum.reduce(to); 
}

지저분하다. 그래도 테스트는 성공하므로 리팩토링을 적용시킬 수 있다.

클래스를 명시적으로 검사하는 코드가 있다면 다형성(polymorphism)을 사용하도록 하자.

Sumreduce() 를 사용하므로 Money 도 그것을 구현하도록 만들자.

그리고 Expression 인터페이스에 reduce() 를 추가시키자.

// Expression.interface
Money reduce(String to);

// Money.class
public Money reduce(String to){
  return this. 
}

이렇게 만든다면 Bank 클래스에 지저분한 클래스 코드를 줄일 수 있다.

// Bank.class
Money reduce(Expression source, String to){
  return sum.reduce(to); 
}

다음 장에서는 통화를 실제로 전환하는 기능을 구현해보자.

우선 지금까지 한 작업을 검토해보자.

  • 모든 중복이 제거되기 전까지는 테스트를 통과한 것으로 치지 않는다.
  • 구현하기 위해 역방향이 아닌 순방향으로 작업했다.
  • 앞으로 필요할 것으로 예상되는 객체(Sum)의 생성을 강요하기 위한 테스트를 작성했다.
  • 빠른 속도로 구현하기 시작했다. (Sum)
  • 일단 한 곳에 캐스팅을 이용해서 구현했다가, 테스트가 들어가자 그 코드를 적당한 자리로 옮겼다.
  • 명시적인 클래스 검사를 제거하기 위해 다형성을 사용했다.

14장. 바꾸기

이번에는 좀 단순한 변화에 대해 생각해볼 것이다.

2프랑이 있는데 이걸 달러로 바꾸고 싶다. 이렇게 써보니 이미 테스트 케이스가 만들어진 것 같다.

public void testReduceMoneyDifferentCurrency(){
  Bank bank = new Bank();
  bank.addRate("CHF", "USD", 2);
  Money result = bank.reduce(Money.franc(2), "USD"); 
  assertEquals(Money.dollar(1), result);
}

그리고 다음 한 줌의 지저분한 코드를 볼 수 있다. 이 코드는 프랑을 달러로 변환할 때 나누기 2를 한다.

// Money.class
public Money reduce(String to){
  int rate = (currency.equals("CHF") && to.equals("USD")) ? 2 : 1;
  return new Money(amount / rate, to); 
}

이 코드로 인해서 갑자기 환율을 알게 돼 버렸다.

환율로 인한 처리는 모두 Bank 가 처리해야 한다.

// Bank.class
public int rate(String from, String to){
  return (from.equals("CHF") && to.equals("USD")) ? 2 : 1;
}

이로 인해 Money 의 처리는 다음과 같이 바뀐다.

// Money.class
public Money reduce(Bank bank, String to){
  int rate = bank.rate(currency, to); 
  return new Money(amount / rate, to); 
}

귀찮은 환율 값 2가 계속해서 나온다.

이를 해결하기 위해서는 Bank 가 환율표를 가지고 있다가 필요할 때 볼 수 있어야 한다.

이를 위해 두 개의 통화와 환율을 매핑시키는 해시 테이블을 사용할 수 있다.

// Pair.class
private class Pair{
  private String from;
  private String to;
  
  Pair(String from, String to){
    this.from = from;
    this.to = to; 
  }
}

우선 Pair 를 키로 쓸 거니까 equals()hashCode() 를 구현해야 한다.

하지만 지금은 리팩토링 중에 코드를 작성하니까 테스트를 작성하지는 않을 것이다.

이제 환율을 저장할 테이블이 필요하다.

// Bank.class
private HashTable rates = new HashTable(); 

환율 설정도 할 수 있어야 한다.

// Bank.class
void addRate(String from, String to, int rate){
  rates.put(new Pair(from, to), new Integer(rate));
}

그리고 필요할 때 환율을 얻어낼 수도 있어야 한다.

// Bank.class
int rate(String from, String to){
  if(from.equals(to)) return 1; 
  Integer rate = (Integer) rates.get(new Pair(from, to)); 
  return rate.intValue(); 
}

다음과 같이 테스트 케이스를 작성하자.

public void testIdentifyRate(){
  assertEquals(1, new Bank().rate("USD", "USD")); 
}

다음 장에서는 마지막 큰 테스트인 $5 + 10CHF 를 구현할 것이다.

일단은 지금까지 한 것을 검토해보자.

  • 필요할 거라고 생각한 인자를 빠르게 추가했다.
  • 코드와 테스트 사이에 있는 데이터 중복을 끄집어냈다.
  • 별도의 테스트 없이 private helper 클래스를 만들었다.

15장. 서로 다른 통화 더하기

드디어 이 모든 작업의 시초인 $5 + 10CHF 에 대한 테스트를 추가할 준비가 됐다.

public void testMixedAddition(){
  Money fiveBucks = Money.dollar(5);
  Money tenFrancs = Money.franc(10);
  Bank bank = new Bank(); 
  bank.addRate("CHF", "USD", 2); 
  Money result = bank.reduce(fiveBucks.plus(tenFrance), "USD"); 
  assertEquals(Money.dollar(10), result);
}

이게 우라가 원하는 테스트 코드다. 컴파일 에러를 하나씩 해결해 나가다고 돌렸다. 하지만 테스트가 실패한다. 10USD 대신 15USD가 결과값으로 나왔다.

Sum.reduce() 가 인자를 축약하지 않는 것 같아 보였다.

다음과 같이 두 인자를 모두 축약하면 테스트가 통과할 것이다.

// Sum.class
public Money reduce(Bank bank, String to){
  int amount = augend.reduce(bank, to).amount + addend.reduce(bank, to).amount;
  return new Money(amount, to); 
}

통과환다. 사실 Expression 이어야 하는 Money 들을 조금씩 쪼아서 없앨 수 있다.

파급 효과를 위해 가장자리부터 하나씩 변화시켜보자.

// Sum.class
Expression augend;
Expression addend;

// Sum.class
Sum(Expression augend, Expression addend){
  this.augend = augend;
  this.addend = addend; 
}

plus() 의 인자도 Expression 으로 취급될 수 있다.

times() 의 인자도 Expression 으로 취급될 수 있다.

이제 테스트 코드는 다음과 같이 바뀔 수 있다.

public void testMixedAddition(){
  Expression fiveBucks = Money.dollar(5);
  Expression tenFrancs = Money.franc(10);
  Bank bank = new Bank(); 
  bank.addRate("CHF", "USD", 2); 
  Money result = bank.reduce(fiveBucks.plus(tenFrance), "USD"); 
  assertEquals(Money.dollar(10), result);
}

그리고 Expressionplus() 가 정의되지 않았으므로 추가해주자.

이제 프로그램이 컴파일되고 테스트도 모두 통과한다.

MoneyExpression 으로 일반화하는 작업을 마무리할 준비가 되었다. 그 전에 지금까지 한 작업을 검토해보자.

  • 원하는 테스트를 작성하고 한 단계에 달성할 수 있도록 뒤로 물렸다.
  • 좀더 추상적인 선언을 통해 가지에서 뿌리로 일반화 했다.
  • 변경 후(Expression fiveBucks), 그 영향을 받은 다른 부분들을 변경하기 위해 컴파일러의 지시를 따랐다.

16장. 드디어, 추상화

Expression.plus() 를 끝마치려면 Sum.plus() 를 구현해야 한다.

그러고나서 Expression.times() 를 모두 구현하면 전체 예제가 끝난다.

다음은 Sum.plus() 에 대한 테스트다.

public void testSumPlusMoney(){
  Expression fiveBucks = Money.dollar(5);
  Expression tenFrancs = Money.franc(10); 
  Bank bank = new Bank();
  bank.addRates("CHF","USD",2);
  Expression sum = new Sum(fiveBucks, tenFrances).plus(fiveBucks); 
  Money result = bank.reduce(sum, "USD");
  assertEquals(Money.dollar(15), result); 
}

fiveBuckstenFrancs 를 더해서 Sum 을 생성할 수 있지만 위에서는 명시적으로 Sum 을 생성한다. 그 이유는 우리의 의도를 더욱 직접적으로 표현할 수 있기 때문이다.

우리가 이 테스트를 작성하는 이유는 우리 혼자 경험하는게 아니라 이걸 읽는 사람도 고려해야 한다.

이를 해결하기 위한 Sum 은 다음과 같다.

// Sum.class
public Expression plus(Expression addend){
  return new Sum(this, addend); 
}

TDD로 구현할 땐 테스트 코드의 줄 수와 모델 코드의 줄 수가 거의 비슷한 상태로 끝난다.

TDD가 경제적이기 위해서는 매일 만들어 내는 코드의 줄 수가 두 배가 되거나 동일한 기능을 구현하되 절반의 줄 수로 해내야 할 것이다.

TDD가 자신의 방법에 비해 어떻게 다른지 직접 즉정해 보아야 할 것이다. 이때 디버깅, 통합 작업, 다른 사람에게 설명하는 데 걸리는 시간 등의 요소를 반드시 포함해야 한다는 것을 기억하길 바란다.

일단 Sum.times() 가 작동하게 만들 수 있다면 Expression.times() 를 선언하는 일이야 어렵지 않다.

테스트는 다음과 같다.

public void testSumTimes(){
  Expression fiveBucks = Money.dollar(5);
  Expression tenFrances = Money.franc(10);
  Bank bank = new Bank();
  bank.addRates("CHF", "USF", 2);
  Expression sum = new Sum(fiveBucks, tenFrancs).times(2);
  Money result = bank.reduce(sum, "USD");
  assertEquals(Money.dollar(20), result); 
}

이를 위한 Sum.times() 는 다음과 같다.

// Sum.class
Expression times(int multiplier){
  return new Sum(augend.times(multiplier), addend.times(multiplier));
}

augendaddend 는 모두 Expression 으로 추상화 했기 때문에 Expressiontimes() 를 선언해야 한다.

이제 테스트는 통과한다.

이제 남은 테스트는 $5 + $5 를 할 때 Money 를 리턴하는지 살펴보면 된다.

테스트는 다음과 같다.

public void testPlusSameCurrencyReturnMoney(){
  Expression sum = Money.dollar(1).plus(Money.dollar(1));
  assertTrue(sum instanceof Money);
}

이 테스트를 위해Moneyplus 는 다음과 같이 작성할 수 있다.

// Money.class
public Expression plus(Expression addend){
  return new Sum(this, addend); 
}

이번 장에서 한 작업들을 검토해보자.

  • 미래에 코드를 읽을 다른 사람들을 염두해 둔 테스트를 작성했다.
  • TDD와 여러분의 현재 개발 스타일을 비교해볼 수 있는 실험 방법을 제시했다.

17장. Money 회고

Money 예제를 만든 과정과 그 결과를 다시 한번 돌아보자.

이 장에서는 다음과 같은 내용을 이야기 할 것이다.

  • 다음에 할 일은 무엇인가
  • 메타포 - 설계 구조에 미치는 메타포의 엄청난 영향
  • JUnit 사용도 - 언제 테스트를 실행했으며 JUnit을 어떻게 사용해 왔는가
  • 코드 매트릭스(metrics) - 결과 코드의 수치와
  • 프로세스 - 빨강 / 초록 / 리팩토링 순서에 대해 이야기 했는데 그렇다면 각 단계에서 얼마 만큼 작업해야 하는가
  • 테스트의 질 - 전통적인 테스트 매트릭스에 TDD 테스트가 어떻게 필적할 수 있는가

다음에 할 일은 무엇인가

이제 코딩은 끝날 걸까? 아직 아니다.

Sum.plus()Money.plus() 사이에 지저분한 중복이 남았다. Expression 을 인터페이스 대신 클래스로 바뀐다면 공통되는 코드를 담아낼 적절한 곳이 될 것이다.

"다음에 할일은 무엇인가?" 에 관련된 또 다른 질문은 "어떤 테스트들이 추가로 더 필요할까"다.

때로는 실패해야 하는 테스트가 성공하는 경우가 있고 그럴 땐 그 이유를 찾아야 한다.

마지막으로, 할 일 목록이 빌 때가 그때가지 설계한 것을 검토하기에 적절한 시기다. 말과 개념이 서로 잘 통하는가? 현재의 설계로는 제거하기 힘든 중복이 있는가?

메타포

Money 예제를 코딩하면서 가장 놀라운 점은 이번 Money 예제 결과가 기존에 했던 것과 많이 다르다는 것이다. 켄트 백은 실무에서 최소 3번 이상 금전 관련 프로그램을 작성했고 출판몰에서도 대여섯 번 이상 예제로 사용했다.

하지만 이 글을 쓰는 동안 expression 메타포를 생각했는데 설계가 기존의 방향과는 완전히 다르게 흘러갔다.

정말 메타포가 이 정도로 막강할 거라고는 생각하지 못했다. 메타포라는 건 단지 이름들을 얻어 내는 데 필요한 것일 뿐이지 않는가? 절대 그렇지 않다.

켄트 백은 Expression 을 사용하기 전에 다양한 메타포를 사용했는데 그 중에는 MoneySum도 있고 MoneyBag 을 거쳐서 Wallet 까지 왔었다.

하지만 이 글을 작성할 때 이 모든 메타포는 Money 의 집합이 딱 떨어지는 숫자로 된다는 걸 알아챘다. 예를 들어 2USD + 5CHF + 3USD 의 결과는 5USD + 5CHF 로 된다는 것이다. 같은 통화의 값을 합칠 수 있다.

Expression 메타포를 통해서 중복되는 통화를 합치는 세세한 일단의 문제를 해결했다. 코드는 그 어느 때보다도 더 명확해져갔다.

JUnit 사용도

Money 예제를 사용하는 동안 JUnit이 로그를 기록하게 두었다. 실행 버튼을 정확히 125번 눌렀었고 코딩하는 동안에 이 글을 쓰는 일도 같이 했다.

코드 메트릭스

코드에 대한 통계
실제 코드테스트 코드
클래스51
함수2215
9189
회기성 복잡도1.041
함수당 줄4.15.9

전체 API를 다 구현한 게 아니기 때문에 함수의 수나 클래스당 함수의 수, 클래스당 줄 수 등을 절대적으로 평가하긴 힘들다. 하지만 그 상대적인 비율에서 교훈을 얻을 수는 있다.

코드와 테스트 사이에 대략 비슷한 양의 함수와 줄이 있는 것을 알 수 있다.

회기성 복잡도(cyclomatic complexity)는 기존의 흐름 복잡도(flow complexity)와 같다. 테스트 코드에 분기나 반복문이 전혀 없기 때문에 테스트 복잡도는 1이다.

명시적인 흐름 제어 대신 다형성을 주로 사용했기 때문에 실제 코드의 복잡도 역시 낮다.

함수의 선언부와 단는 코드도 여기선 포함되었다.

테스트의 함수당 줄 수가 과장되었는데 이는 공통된 픽스처 구축 코드를 추출하지 않았기 때문이다.

프로세스

TDD의 주기는 다음과 같다.

  • 작은 테스트를 추가한다
  • 모든 테스트를 실행하고, 실패하는 것을 확인한다.
  • 코드에 변화를 준다.
  • 모든 테스트를 실행하고, 성공하는 것을 확인한다.
  • 중복을 제거하기 위해 리팩토링한다.

테스트의 질

TDD의 부산물로 자연히 생기는 테스트들은 시스템의 수명이 다할 때까지 함께 유지돼야 할 만큼 유용하다.

하지만 이 테스트들이 다음과 같은 다른 종류의 테스트들을 대체할 것이라고 생각해서는 안된다.

  • 성능 테스트
  • 스트레스 테스트
  • 사용성 테스트

최종 검토

TDD를 가르칠 때 사람들이 자주 놀라는 세 가지는

  • 테스트를 확실히 돌아가게 만드는 세 가지 접근법: 가짜로 구현하기, 삼각측량법, 명백하게 구현하기
  • 설계를 주도하기 위한 방법으로 테스트 코드와 실제 코드 사이의 중복을 제거하기
  • 길이 미끄러우면 속도를 줄이고 상황이 좋으면 속도를 높이는 식으로 테스트 사이의 간격을 조절할 수 있는 능력
profile
좋은 습관을 가지고 싶은 평범한 개발자입니다.

0개의 댓글