본문 바로가기
Books

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

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

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

 

 

1부에서는 완전히 테스트에 의해 주도되는 전형적 모델 코드 개발 과정을 따라간다. 1장부터 5장까지는 상위 클래스로 추상화를 하기 이전에 테스트 주도 개발의 주기를 따라가는 기본적인 내용이 등장한다.

 

테스트 주도 개발 (TDD) 의 리듬은 다음과 같이 요약할 수 있다.

 

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

 

이러한 리듬을 통해 다음을 배울 수 있을 것이다.

 

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

 

1부에는 달러 (Dollar) 만을 지원하는 기존의 채권 포트폴리오 관리 시스템에서 다중 통화를 지원하도록 변경하는 예시가 등장한다.

 

종목 가격 합계
IBM 1000 25 25000
GE 400 100 40000
    합계 65000

 

다중 통화를 지원하기 위해 다음과 같이 통화 단위를 추가해야한다.

 

종목 가격 합계
IBM 1000 25USD 25000USD
GE 400 150CHF 60000CHF
    합계 65000USD

 

그리고 환율을 명시해야한다.

 

기준 변환 환율
CHF USD 1.5

 

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

할일 목록 만들기

새로운 요구사항을 지원하기 위해 다음의 기능이 있어야 한다. 다시말하면, 다음의 테스트들이 있어야 변경된 요구사항을 지원하는 코드가 완성되었다고 확신할 수 있다.

 

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

앞으로 어떤 일을 해야 하는지 할일 목록을 작성하고, 할일 목록에 있는 한 항목에 대한 작업을 시작하면 굵은 글씨체로, 작업이 끝나면 줄을 긑는다. 그리고 할일 목록에 작업을 다룰 땐, 어떤 객체를 만들면서 시작하는게 아니라 테스트를 먼저 만들어야 한다!

 

테스트 만들기

그렇다면 어떤 테스트가 필요할까? 할일 목록을 보면 1번째 테스트는 복잡해 보이는데, 이런 경우에는 작은 것부터 시작하든지, 아니면 아예 손을 대지 않는 게 좋다. 다음 항목인 곱하기는 어렵지 않아보이므로, 먼저 테스트를 만들자.

 

테스트를 작성할 때는 오퍼레이션의 완벽한 인터페이스에 대해 상상해보는 것이 좋다. 즉, 가능한 최선의 API 에서 시작하여 거꾸로 작업한다. 아래는 곱하기에 대한 간단한 테스트의 예로, 테스트 내에 문제들이 보이지만 작은 단계로 시작하기 위해 문제들을 적어 놓고 계속 진행하자. 실패하는 테스트가 주어진 상태에서 최대한 빨리 초록 막대를 보는 것이 목표이다.

 

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

 

테스트 통과하기

작성한 코드는 컴파일조차 되지 않으므로, 먼저 컴파일이 되게 만들어야 한다. 따라서 아래 4개의 컴파일 에러에 대하여 하나씩 정복해 나가자.

 

  • Dollar 클래스가 없음
  • Dollar 생성자가 없음
  • times(int) 메서드가 없음
  • amount 필드가 없음
// Dollar 클래스가 없음
// Dollar.java
class Dollar { }

// Dollar 생성자가 없음
// Dollar.java
Dollar(int amount) {}

// times(int) 메서드가 없음
// Dollar.java
void times(int multiplier) {}

// amount 필드가 없음
// Dollar.java
int amount;

 

위와 같이 수정할 경우, 컴파일 에러는 없지만 예상값과 결과값이 달라 테스트가 실패한다. 하지만 이것도 일종의 진척으로, 실패에 대한 구체적인 척도를 갖게 되었다. 즉, ‘다중 통화 구현’ 에서 ‘이 테스트를 통과시킨 후 나머지 테스트들도 통과시키기’로 변형된 것이므로 할일이 간단해졌다. 그리고 당장의 당장의 목표는 완벽한 해법을 구하는게 아니라 테스트를 통과하는 것 뿐이다!

 

오로지 테스트를 통과하기 위한 최소의 작업은 아래와 같다.

 

// Dollar.java
int amount = 10;

 

중복 제거하기

이제 테스트를 통과할 수 있고, 따라서 TDD 의 주기에서 1~4번 항목까지 수행했다. 이제 중복을 제거할 차례인데, 이번 경우에는 중복이 테스트에 있는 데이터 (5, 2) 와 코드에 있는 데이터 (10) 사이에 존재한다. 따라서 다음과 같이 중복을 제거할 수 있다.

 

// Dollar.java
int amount = 5 * 2;

// Dollar.java
int amount;
void times(int multiploer) {
    amount = 5 * 2;
}

 

중복 제거 단계가 너무 작게 느껴질 수 있다. 하지만 TDD 의 핵심은 이런 작은 단계를 밟아야 한다는 것이 아니라, 이런 작은 단계를 밟을 능력을 갖추어야 한다는 것이다. (=넓은 단계도 작은 단계로부터 수행할 줄 알아야 한다.)

다시 중복을 제거하면 아래와 같다.

 

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

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

// multiplier 값이 2이므로, 인자를 상수로 대체할 수 있다
// Dollar.java
void times(int multiplier) {
    amount *= multiplier;
}

할일 목록

이제 1번째 테스트에 대해 완료 표시를 할 수 있게 되었다.

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar 부작용?
  • Money 반올림?

 

[2장] 타락한 객체

TDD 주기와 목적

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

 

  1. 테스트를 작성한다.
    마음속에 있는 오퍼레이션이 코드에 어떤 식으로 나타나길 원하는지 생각하고, 원하는 오프레이션을 바탕으로 인터페이스를 개발한다.

  2. 실행 가능하게 만든다.
    다른 무엇보다도 중요한 것은 빨리 초록 막대를 보는 것이다. 깔끔하고 단순한 해법이 바로 보인다면 그것을 입력하고, 구현하는데 시간이 걸릴 것 같으면 일단 적어 놓은 뒤에 초록 막대를 보는 것에 집중하자.

  3. 올바르게 만든다.
    이제 시스템이 작동하므로 중복을 제거하고 초록 막대로 되돌리자.

우리의 목적은 ‘작동하는 깔끔한 코드’를 얻는 것이다. 하지만 이는 힘든 목표이므로 나누어 정복하자 (divide and conquer). ‘작동하는 깔끔한 코드’를 얻어야 한다는 전체 문제 중에서 ‘작동하는’ 에 해당하는 부분을 먼저 해결하고 나서, ‘깔끔한 코드’ 부분을 해결한다.

 

TDD 주기

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

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar부작용?
  • Money 반올림?

테스트는 통과했지만, Dollar 에 대한 연산 수행 시 Dollar 의 값이 바뀐다. 따라서 다음과 같이 새로 테스트를 만들 수 있다.

 

테스트 만들기

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

 

이제 테스트를 통과하자. times() 에서 처음 호출한 이후 five 는 더 이상 5 값을 가지지 않는데, times() 에서 새로운 객체를 반환하게 만들자. 이렇게 하면 Dollar 의 인터페이스를 수정해야하고, 테스트도 수정해야한다. 하지만 어떤 구현이 올바른가에 대한 우리의 추측이 완벽하지 못한 것과 마찬가지로 올바른 인터페이스에 대한 추측도 역시 절대 완벽하지 못하다. 빨리 초록 막대를 보는데 집중하자.

 

테스트 수정하기

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

 

 

가짜로 구현하기

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

 

이제 테스트가 컴파일되지만 실행되지는 않는다. 따라서 테스트를 통과하기 위한 구현을 해야한다.

 

실제로 구현하기

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

 

최대한 빨리 초록 막대를 보기 위해 취할 수 있는 전략은 2가지이다.

 

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

모든 일이 자연스럽게 잘 진행되고 내가 뭘 입력해야 할지 알 때는 명백한 구현을 계속 더해 나간다. 하지만 예상치 못한 빨간 막대를 만나게 되면 뒤로 한발 물러서서 가짜로 구현하기 방법을 사용하면서 올바른 코드로 리팩토링한다. 그러다 다시 자신감을 되찾으면 명백한 구현하기 모드로 돌아온다.

정리

위의 TDD 주기 과정을 살펴보면 아래와 같다.

 

  1. 설계상의 결함 (Dollar 부작용) 을 그 결함으로 인해 실패하는 테스트로 변환했다.
  2. 스텁 구현으로 빠르게 컴파일 하도록 만들었다.
  3. 올바르다고 생각하는 코드를 입력하여 테스트를 통과했다.

즉, 느낌 (위에서는 부작용에 대한 혐오감) 을 테스트 (위에서는 하나의 Dollar 객체에 곱하기를 2번 수행하는 것) 으로 변환하는 것은 TDD 의 일반적 주제다.

 

[3장] 모두를 위한 평등

위에서 Dollar 객체같이 객체를 값으로 쓸 수 있는데, 이것을 값 객체 패턴 (value object pattern) 이라고 한다. 값 객체에 대한 제약사항 중 하나는 객체의 인스턴스 변수가 생성자를 통해서 일단 설정된 후에는 결코 변하지 않는다는 것이다. 즉, 값 객체를 사용하면 별칭 문제에 대해 걱정할 필요가 없다.

 

값 객체는 아래와 같은 특징을 갖는다.

 

  • 값 객체를 사용하면 별칭에 대해 걱정할 필요가 없다. 해당 값 객체는 변하지 않고 영원히 해당 값임을 보장받으며, 어떤 값 객체의 값을 바꾼다고 해서 다른 값 객체의 값이 바뀌지 않는다.
  • 값 객체를 사용하면 모든 연산은 새 객체를 반환해야한다.
  • 값 객체는 equals() 를 구현해야한다.

값 객체의 구현을 포함한 현재 할일 목록은 다음과 같다. 값 객체를 해시 테이블의 키로 쓸 생각이면 equals() 를 구현할 때 hashCode() 를 함께 구현해야하므로 할일 목록에 추가했다.

 

  • $5 + 10CHF = $10
  • $5 * 2 = $10
  • amountprivate 으로 만들기
  • Dollar 부작용?
  • Money 반올림?
  • equals()
  • hashCode()

테스트 만들기

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

 

가짜로 구현하기

// Dollar.java
public boolean equals(Object object) {
    return true;
}

 

삼각 측량

만약 라디오 신호를 2개의 수신국이 감지하고 있을 때, 수신국 사이의 거리가 알려져 있고 각 수신국이 신호의 방향을 알고 있다면, 이 정보들만으로 신호의 거리와 방위를 구하는 계산법을 삼각측량이라고 한다. 테스트에서 삼각측량을 이용하여, 예제가 2개 이상 있으면 코드를 일반화할 수 있다.

 

테스트 만들기 (2번째)

// test
public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5));
    assertFlase(new Dollar(5).equals(new Dollar(6));
}

 

2번째 테스트로 삼각측량에 의해 더 일반적인 해를 필요로 할 때, 오로지 그때만 비로소 일반화한다.

 

진짜로 구현하기

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

 

한 번에 끝낼 수 있는 일을 두고 또다른 테스트를 만들 필요는 없다. 코드와 테스트 사이의 중복을 제거하고 일반적인 해법을 구할 방법이 보이면 그 방법대로 구현하고, 어떻게 리팩토링해야 하는지 전혀 감이 안올 때만 삼각측량을 사용한다.

정리

위의 TDD 주기 과정을 살펴보면 아래와 같다.

 

  1. 디자인 패턴 (값 객체) 이 다른 오퍼레이션 (equals) 를 암시한다는 것을 알고, 해당 오퍼레이션 테스트를 추가했다.
  2. 해당 오퍼레이션을 테스트했다.
  3. 해당 오퍼레이션을 간단히 구현했다.
  4. 곧장 리팩토링하는 대신 테스트를 조금 더 했다. (삼각측량)
  5. 1과 4의 경우를 모두 수용할 수 있도록 리팩토링했다.

 

[4장] 프라이버시

현재 할일 목록은 다음과 같다. 앞에서 equals() 를 구현할 때 null 값이나 다른 객체들과 비교해야하는 부분을 할일 목록에 추가하였다.

 

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

테스트 고치기

개념적으로 Dollar.times() 연산은 호출받은 객체의 값에 인자로 받은 곱수 만큼 곱한 값을 Dollar 를 반환해야하지만, 앞으로 테스트가 정확히 그것을 말하지는 않는다.

 

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

 

기존의 위 테스트에서 Dollar 객체를 반환하도록 수정한다.

 

테스트 고치기

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

 

테스트를 고치고 난 후 Dollaramount 인스턴스 변수를 사용하는 코드는 Dollar 자신밖에 없게 됐다. 따라서 변수를 private 으로 변경할 수 있다.

 

진짜로 구현하기

// Dollar.java
private int amount;

 

하지만 고친 테스트는 위험한 상황을 만들었다. 만약 동치성 테스트가 동치성에 대한 코드가 정확히 작동한다는 것을 검증하는데 실패한다면, 곱하기 테스트 역시 곱하기에 대한 코드가 정확하게 작동한다는 것을 검증하는데 실패하게 된다.

정리

위의 TDD 주기과정을 살펴보면 아래와 같다.

 

  1. 오직 테스트를 향상시키기 위해서만 개발된 기능을 사용했다.
  2. 테스트와 코드 사이의 결합도를 낮추기 위해, 테스트하는 객체의 새 기능을 사용했다.
  3. 두 테스트가 동시에 실패하면 망한다는 위험요소가 있음에도 진행했다.

 

[5장] 솔직히 말하자면

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

 

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

위 목록에서 첫번쨰 테스트는 너무 큰 발걸음이므로, 작은 단계 하나로 구현하는 테스트를 작성해낼 수 있을지 확실하지 않다. 우선 Dollar 객체와 비슷하지만 달러 대신 프랑을 표현할 수 있는 객체가 필요해 보이므로, Franc 이라는 객체를 만들고 Dollar 테스트를 복사한 후 수정해보자.

 

테스트 만들기

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

 

작은 단계를 밟아 초록 막대를 보기 위해, Dollar 클래스의 코드를 복사해서 Franc 으로 바꾼다. 그런데 코드의 재사용과 추상화의 부재는 깨끗한 설계와 거리가 멀어보인다.

 

TDD 주기

 

  1. 테스트 작성
  2. 컴파일되게 하기
  3. 실패하는지 확인하기 이해 실행
  4. 실행하게 만듦
  5. 중복 제거

하지만 위 TDD 주기에서 1~4단계는 빨리 진행해야 한다. 그러면 새 기능이 포함되더라도 잘 알고 있는 상태에 이를 수 있다. 해당 단계에서는 속도가 설계보다 더 높은 가치가 있으므로, 거기에 도달하기 위해서라면 어떤 죄든 저지를 수 있다. 그렇다고 주기의 5단계가 없이는 앞의 4단계도 제대로 되지 않는다. 따라서 돌아가고 만들고, 그 다음에 올바르게 만들자.

 

// Franc.java
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;
    }
}

 

정리

위의 TDD 주기를 살펴보면 아래와 같다.

 

  • 큰 테스트를 공략할 수 없으므로, 진전을 보일 수 있는 작은 테스트를 만들었다.
  • 뻔뻔스럽게도 중복을 만들고 조금 고쳐서 테스트를 작성하고, 중복을 만들고 조금 고쳐서 모델 코드를 작성해 테스트를 통과했다.

 

댓글