테스트 주도 개발(켄트백) - 3부. 테스트 주도 개발의 패턴(1)

728x90

 

25장. 테스트 주도 개발 패턴

우선 기본적인 전략에 관한 질문에 답해야 한다.

  • 테스트한다는 것을 무엇을 뜻하는가?
  • 테스트를 언제 해야 하는가?
  • 테스트할 로직을 어떻게 고를 것인가?
  • 테스트할 데이터를 어떻게 고를 것인가?

(위에 처럼 글로 정리를 안해서 그렇지 위와 같은 문제점에 혼자서 답을 얻기 힘들었기 때문에 이 책을 읽고있는게 아닌가 하다) 

| 테스트(명사) |

자동화된 테스트를 만들어라! 테스트하다(test)는 '평가하다'라는 뜻의 동사다. 그 어떤 소프트웨어 엔지니어도, 아무리 작은 변화라도 테스트하지 않고 릴리즈하지는 않는다. 변화를 테스트할 수 있다고 해도, 실제로 변화를 테스트하는 것은 '테스트를 갖고 있다'는 것과는 같지 않다. 

| 격리된 테스트 |

테스트를 실행하는 것이 어떤 식으로 영향을 미쳐야 좋은가? 아무 영향이 없어야 한다. 각각의 테스트는 다른 테스트와 완전히 독립적이어야 한다. 즉 문제가 하나면 테스트도 하나만 실패해야 하고, 문제가 둘이면 테스트도 두 개만 실패해야 한다. 

격리된 테스트가 암묵적으로 내포하는 특징 중 하나는 테스트가 실행순서에 독립적이게 된다는 점이다. (테스트의 일부만 실행해보고 싶으면, 선행 테스트가 실행되지 않아서 내가 고른 테스트들이 실패하지 않을까 걱정할 필요 없이 그렇게 할 수 있어야 한다) 

성능 문제는 테스트가 데이터를 공유해야 하는 이유로 자주 언급된다. 격리된 테스트가 내포하는게 또 하나 있는데, 이는 주어진 문제를 작은 단위로 분리하기 위해 노력해서 각 테스트를 실행하기 위한 환경을 쉽고 빠르게 세팅할 수 있게 해야 한다는 것이다. 테스트를 격리하기 위한 작업은 결과적으로 시스템이 응집도는 높고, 결합도는 낮은 객체의 모음으로 구성되도록 한다

| 테스트 목록 |

뭘 테스트해야 하나? 시작하기 전에 작성해야 할 테스트 목록을 모두 적어둘 것. 우선 구현할 필요가 있는 모든 오퍼레이션의 사용예들을 적는다. 그 다음, 아직 존재하지 않는 오퍼레이션에 대해서는 해당 오퍼레이션의 널 버전(아무 일도 하지 않는 버전)을 리스트에 적는다. 마지막으로 깔끔한 코드를 얻기 위해 이번 작업을 끝내기 전에 반드시 해야할 리팩토링 목록을 적는다. 테스트의 윤곽만 잡는 대신, 한 걸음 더 나아가 테스트를 전부 구현할 수도 있다. (But 그렇게 추천하지는 않음, 실제로 초록 막대를 보는데 걸리는 시간이 상당히 길어지기 때문에 TDD와는 조금 거리가 멀어진다고 생각이 듦)

테스트를 통과하게 만드는 과정에서 우리가 작성한 코드들은 새로운 테스트가 필요함을 암시적으로 알려줄 것이다. 이 새 테스트를 리팩토링과 마찬가지로 할일 목록에 적어 놓아라. 세션이 끝났을 때 목록에 남아 있는 항목들은 따로 신경 쓸 필요가 있다. 어떤 기능을 하나 진행하는 중이라면 다음 번에도 똑같은 목록을 사용하라. 현재 작업 범위를 넘어서는 큰 리팩토링 거리를 발견한다면, '다음' 할일 목록으로 옮겨라. 

| 테스트 우선 |

테스트를 언제 작성하는 것이 좋을까? 테스트 대상이 되는 코드를 작성하기 직전에 작성하는 것이 좋다. 코드를 작성한 후에는 테스트를 만들지 않을 것이다. 프로그래머로서 우리의 목표는 기능이 실행되도록 만드는 것이다. 하지만 또 한편으로는 프로그램의 설계에 대해 생각해볼 시간도 필요하고 작업 범위를 조절할 방법도 필요할 것이다. 

| 단언 우선 | 

테스트를 작성할 때 단언(assert)를 언제쯤 쓸까? 단언을 제일 먼저 쓰고 시작하라

  • 시스템을 개발할 때 무슨 일부터 하는가? 완료된 시스템이 어떨 거라고 알려주는 이야기부터 작성한다.
  • 특정 기능을 개발할 때 무슨 일부터 하는가? 기능이 완료되면 통과할 수 있는 테스트부터 작성한다.
  • 테스트를 개발할 때 무슨 일부터 하는가? 완료될 때 통과해야 할 단언부터 작성한다. 

단언을 먼저 작성하면 작업을 단순하게 만드는 강력한 효과를 볼 수 있다. 구현에대해 전혀 고려하지 않고 테스트만 작성할 때도 사실 우리는 몇 가지 문제들을 한번에 해결하는 것이다. 

  • 테스트하고자 하는 기능이 어디에 속하는 걸까? 기존의 메서드를 수정해야 하나, 기존의 클래스에 새로운 메서드를 추가해야 하나, 아니면 이름이 같은 메서드를 새 장소에? 또는 새 클래스에?
  • 메서드 이름은 뭐라고 해야 하나?
  • 올바른 결과를 어떤 식으로 검사할 것인가?
  • 이 테스트가 제안하는 또 다른 테스트에는 뭐가 있을까? 

이 문제를 한번에 잘 해결하기에는 쉽지가 않다.  '올바른 결과는 무엇인가?', '어떤 식으로 검사할 것인가?;는 나머지 문제에서 쉽게 분리할 수 있다. 

 

예를 들어, 소켓을 통해 다른 시스템과 통신하려 한다고 가정할 때, 통신을 마친 후 소켓은 닫혀 있고, 소켓에서 문자열 'abc'를 읽어와야 한다고 치자. 

testCompleteTransaction() {
    ....
    assertTrue(reader.isClosed());
    assertEquals("abc", reply.contents());
}

reply는 어디에서 얻어오나? 물론 socket이다. 

testCompleteTransaction() {
    ...
    Buffer reply = reader.contents();
    assertTrue(reader.isClosed());
    assertEquals("abc", reply.contents());
}

그럼 socket은 어디에서 나오나? 서버에 접속할 때 생성된다. 

testCompleteTransaction() {
    ...
    Socket reader = Socket("localhost", defaultPort());
    Buffer reply = reader.contents();
    assertTrue(reader.isClosed());
    assertEquals("abc", reply.contents());
}

물론 이 작업을 하기 전에 서버를 먼저 열어야 한다. 

testCompleteTransaction() {
    Server writer = Server(defaultPort(), "abc");
    Socket reader = Socket("localhost", defaultPort());
    Buffer reply = reader.contents();
    assertTrue(reader.isClosed());
    assertEquals("abc", reply.contents());
}

아직 실제 용도에 맞게 이름을 수정하는 일이 남아 있긴 하지만 지금까지 아주 작은 단계로 빠른 피드백을 받으며 테스트의 아웃라인을 만들었다. 

| 테스트 데이터 |

테스트할 때 어떤 데이터를 사용해야 하는가? 테스트를 읽을 때 쉽고 따라가기 좋을 만한 데이터를 사용하라. 데이터 작성에도 청중이 존재한다. 단지 데이터 값을 산발하기 위해 데이터 값을 산발하지 마라. 데이터 간에 차이가 있다면 그 속에 어떤 의미가 있어야 한다. (1과 2사이에 어떠한 개념적 차이도 없다면 1을 사용하라) 

테스트 데이터 패턴이 완전한 확신을 얻지 않아도 되는 라이선스는 아니다. 만약 시스템이 여러 입력을 다루어야 한다면 테스트 역시 여러 입력을 반영해야 한다. 하지만 세 항목만으로 동일한 설계와 구현을 이끌어낼 수 있다면 굳이 항목을 열 개나 나열할 필요는 없다.

테스트 데이터 패턴의 한 가지 트릭은 여러 의미를 담는 동일한 상수를 쓰지 않는 것이다. 만약 plus() 메서드를 구현하려고 한다면 고전적 예제인 2+2 혹은 1+1을 쓰고 싶을 것이다. 만약 구현에서 인자의 순서가 뒤집힌다면 어떻게 될까? (plus()에서야 순서가 뒤집혀도 상관이 없겠지만 다른 메서드는 아닐수도 있다 )우리가 첫 번째 인자로 2를 썼다면 두 번째 인자는 3을 써야 한다. 테스트 데이터에 대한 대안은 실제 세상에서 얻어진 실제 데이터를 사용하는 것이다. 실제 데이터는 다음과 같은 경우에 유용하다. 

  • 실제 실행을 통해 수집한 외부 이벤트의 결과를 이용하여 실시간 시스템을 테스트하고자 하는 경우.
  • 예전 시스템의 출력과 현재 시스템의 출력을 비교하고자 하는 경우 (병렬 테스팅)
  • 시뮬레이션 시스템을 리팩토링한 후 기존과 정확히 동일한 결과가 나오는지 확인하고자 할 경우. 특히 부동소수점 값의 정확성이 문제가 될 수 있다.

| 명백한 데이터 |

데이터의 의도를 어떻게 표현할 것인가? 테스트 자체에 예상되는 값과 실제 값을 포함하고 이 둘 사이의 관계를 드러내기 위해 노력하라. 테스트를 작성할 때는 컴퓨터뿐 아니라 후에 코드를 읽을 다른 사람들도 생각해야한다. 

예를 들어서, 한 통화를 다른 통화로 환전하려고 하는데, 이 거래에는 수수료 1.5%가 붙는다. USD에서 GBP로 교환하는 환율이 2:1이라면 $100를 환전하려면 50GBP - 1.5% = 49.25GBP여야 한다. 이 테스트는 아래와 같이 쓸 수 있다. 

Bank bank = new Bank();
bank.addRate("USD", "GBP", STANDARD_RATE);
bank.commission(STANDARD_COMMISSION);
Money result = bank.convert(new Note(100, "USD"), "GBP);
assertEquals(new Note(49.25, "GBP"), result);

 또는 계산을 더 명확히 표현할 수도 있다 .

Bank bank = new Bank();
bank.addRate("USD", "GBP", 2);
bank.commission(0.015);
Money result = bank.convert(new Note(100, "USD"), "GBP");
assertEquals(new Note(100 / 2 * (1 - 0.015), "GBP"), result);

이 테스트에서는 입력으로 사용된 숫자와 예상되는 결과 사이의 관계를 읽어낼 수가 있다. 

명백한 데이터가 주는 또 다른 이점프로그래밍이 더 쉬워진다는 것이다. 단언 부분에 일단 수식을 써놓으면 다음으로 무엇을 해야 할지 쉽게알게 된다. 이런 경우 어떻게든 나눗셈과 곱셈을 수행할 프로그램을 만들어야 한다는 걸 알게 되는 것이다. 이 오퍼레이션이 어디에 속할지를 점진적으로 알아내기 위해 가짜 구현을 해볼 수도 있다.

명백한 데이터는 코드에 매직넘버를  쓰지 말라는 것에 대한 예외적인 규칙일 수도 있다. 단일 메서드의 범위에서라면 어떤 매직넘버 사이의 관계는 명백하다. 하지만 이미 정의된 기호 상수가 있다면 나는 그것을 사용할 것이다. 

 


26장. 빨간 막대 패턴 

이 패턴들은 테스트를 언제 어디에 작성할 것인지, 테스트 작성을 언제 멈출지에 대한 것이다. 

| 한 단계 테스트 |

목록에서 다음 테스트를 고를 때 무엇을 기준으로 할 것인가? 본인에게 새로운 무언가를 가르쳐줄 수 있으며, 구현할 수 있다는 확신이 드는 테스트를 고를 것.

각 테스트는 우리를 최종 목표로 한 단계 진전시켜 줄 수 있어야 한다. 다음 테스트 목록 중 무엇을 고르는게 좋을까?

  • 더하기
  • 빼기
  • 곱하기
  • 나누기
  • 비슷한 것 더하기
  • 동치성(equals)
  • 널과의 동치성(equals null)
  • 널 환전
  • 한 개의 통화를 환전하기
  • 두 개의 통화를 환전하기 
  • 환시세 

이중에서 어떤 것을 두고 해도 상관없다. (본인이 진짜로 구현이 가능하다고 확신하는 것을 고르자) 

전체 계산 중 간단한 하나의 사례를 나타내는 테스트에서 시작했다면, 이 테스트를 통해 자라는 프로그램은 하향식(top-down)으로 작성된 것으로 보일 수 있다. 반면 전체의 작은 한 조각을 나타내는 테스트에서 시작하여 조금씩 붙여나가는 식이었다면, 이 프로그램은 상향식(bottom-up)으로 작성된 것으로 보일 수 있다. 

사실은 상향식, 하향식 둘 다 TDD의 프로세스를 효과적으로 설명해 줄 수 없다. 첫째로 이와 같은 수직적 메타포는 프로그램이 시간에 따라 어떻게 변해 가는지에 대한 단순화된 시각일 뿐이다. 이보다 성장이란 단어를 보자. '성장'은 일종의 자기유사성을 가진 피드백 고리를 암시하는데, 이 피드백 고리에서는 환경이 프로그램에 영향을 주고 프로그램이 다시 환경에 영향을 준다. 둘째로, 만약 메타포가 어떤 방향성을 가질 필요가 있다면 (상향 혹은 하향보다는) '아는 것에서 모르는 것으로' 라는 방향이 유용할 것이다. '아는 것에서 모르는 것으로'는 우리가 어느 정도의 지식과 경험을 가지고 개발을 시작한다는 점, 개발하는 중에 새로운 것을 배우게 될 것임을 예상한다는 점 등을 암시한다. (우리는 아는 것에서 모르는 것으로 성장하는 프로그램을 갖게 된다)

| 시작 테스트 |

어떤 테스트부터 시작하는 게 좋을까? 오퍼레이션이 아무 일도 하지 않는 경우를 먼저 테스트할 것.

새 오퍼레이션에 대한 첫 질문은 다음과 같을 것이다. "이 오퍼레이션을 어디에 넣어야 하지?" 이 질문에 답하기 전까지는 테스트에 뭘 적어야 할지 알 수 없을 것이다. 한 번에 한 문제만 해결하자는 의미에서 다른 질문은 다 빼고 딱 이 질문만 생각할 방법은 무엇인가? 

첫 걸음으로 현실적인 테스트를 하나 작성한다면 상당히 많은 문제를 한번에 해결해야 하는 상황이 될 것이다.

  • 이 오퍼레이션을 어디에 두어야 하나?
  • 적절한 입력 값은 무엇인가?
  • 이 입력들이 주어졌을 때 적절한 출력은 무엇인가?

현실적인 테스트 하나로 시작하면 너무 오랫동안 피드백이 없을 것이다. 빨강/초록/리팩토링, 빨강/초록/리팩토링. 우리는 이 고리가 몇 분 이내로 반복되길 원할 것이다.

정말 발견하기 쉬운 입력과 출력을 사용하면 이 시간을 짧게 줄일 수 있다. (무슨말인지 이해가 잘 안된다. 걱정하지말자 책에서 설명하는 예제를 곧이 곧대로 적어보겠다.... 고..고갱뉨 당황하쎠서요)

예를 들어 XP 뉴스그룹에 누군가가 다각형 축소기를 테스트 우선으로 어떻게 작성할지 질문했다. 입력은 다각형 그물이고, 출력은 정확하게 똑같은 표면이면서 가능한 한 최소 개수의 다각형으로 구성된 다각형 그물이 된다. "테스트를 작동하도록 하는 데 박사 학위 논문을 읽어야 하는 경우, 이 문제를 어떻게 테스트 주도로 접근할 수 있을까요?" 시작 테스트 패턴이 이 문제에 대한 답을 준다. 

  • 출력이 입력과 같은 경우가 있다. 어떤 형상(configuration)의 다각형들은 이미 정규화되어 있고 더 축소할 수 없다.
  • 입력은 가능한 한 적어야 한다. 이를테면 다각형 하나 또는 아예 비어있는 다각형 목록일 수도 있다. 

나는 다음 시작 테스트를 작성했다.

Reducer reducer = new Reducer(new Polygon());
assertEquals(0, reducer.result().npoints);

첫 번째 테스트가 돌아간다. 이제 목록에 있는 나머지 테스트를 처리 할 차례다. 

한 단계 테스트는 시작 테스트에도 적용된다. 본인에게 뭔가를 가르쳐줄 수 있으면서도 빠르게 구현할 수 있는 테스트를 선택하라. 만약 본인이 어떤 애플리케이션을 n번째 구현하고 있다면, 오퍼레이션을 한두 개 필요로 하는 테스트를 하나 골라라. 본인 스스로 그걸 작동하게 할 수 있을 거라 자신할 것이다. 많은 경우 나의 시작 테스트는 그 이후의 테스트에 비해 좀더 높은 차원의 테스트로, 애플리케이션 테스트와 비슷하다.

 

| 설명 테스트 |

자동화된 테스트가 더 널리 쓰이게 하려면 어떻게 해야 할까? 테스트를 통해 설명을 요청하고 테스트를 통해 설명하라. TDD의 사용을 강요하는 것 만큼 TDD가 퍼지는 것을 빨리 막는 방법은 없다. 타인의 일하는 방식을 강제로 바꿀 수는 없다. 어떻게 해야 하나? 단순한 시작법은 테스트를 이용하여 묻고, 테스트를 이용하여 설명하는 것이다

| 학습 테스트 | 

외부에서 만든 소프트웨어에 대한 테스트를 작성해야 할 때도 있을까? 패키지의 새로운 기능을 처음으로 사용해보기 전에 작성할 수 있다. 예를들어 자바의 모바일 정보 기기 프로파일(MIDP) 라이브러리를 기반으로 뭔가를 만들어야 한다고 치자. RecordStore에 어떤 데이터를 저장하고 이를 받아오고자 한다. 그냥 코딩하고선 그게 잘 돌아가길 바라는 게 좋을까? 물론 그것도 하나의 방법이기는 하다. 한 가지 대안은 우리가 이제 막 새 클래스의 새 메서드를 하나 사용한다는 것을 알아채는 것이다. 그냥 바로 사용하는 대신 API가 우리 예상대로 실행된다는 것을 확인해줄 만한 작은 테스트를 만들어 보는 것이다. 즉 다음과 같다. 

public void setUp() {
    store = RecordStore.openRecordStore("testing", true);
}

public void tearDown() {
    RecordStore.deleteRecordStore("testing");
}

public void testStore() {
    int id = store.addRecord(new byte[] {5, 6}, 0, 2);
    assertEquals(2, store.getRecordSize(id);
    byte[] buffer = new byte[2];
    assertEquals(2, store.getRecord(id, buffer, 0));
    assertEquals(5, buffer[0]);
    assertEquals(6, buffer[1]);
}

만약 우리가 API를 제대로 이해했다면 이 테스트는 한번에 통과할 것이다. 아래는 학습 테스트를 작성하는 관례를 설명한다. 

  • 패키지의 새 버전이 도착하면 우선 테스트를 실행한다(그리고 필요하다면 수정한다).
  • 만약 테스트가 통과되지 않는다면 애플리케이션 역시 실행되지 않을 것이 뻔하기 때문에 애플리 케이션을 실행해볼 필요도 없다.
  • 일단 테스트가 통과한다면 애플리케이션은 항상 제대로 돌아갈 것이다

 

| 또 다른 테스트 |

어떻게 하면 주제에서 벗어나지 않고 기술적인 논의를 계속할 수 있을까? 주제와 무관한 아이디어가 떠오르면 이에 대한 테스트를 할일 목록에 적어놓고 다시 주제로 돌아와라.

대화를 엄격하게 한 주제로 묶는 것은 훌륭한 아이디어를 억압하는 최고의 방법이다. 하루 온종일 비생산적인 날들을 보낸 경험에서, 내가 가야 할 길을 놓치지 않는 것이 때로는 최선임을 배웠다. 새 아이디어가 떠오르면 존중하고 맞이하되 그것이 내 주의를 흩뜨리지 않게 한다. 그 아이디어를 리스트에 적어놓고는 하던 일로 다시 돌아간다. 

 

| 회귀 테스트 |

시스템 장애가 보고될 때 우리는 무슨 일을 제일 먼저 하는가? 그 장애로 인하여 실패하는 테스트, 그리고 통과할 경우엔 장애가 수정되었다고 볼 수 있는 테스트를 가장 간단하게 작성하라

회귀 테스트(regression test)란, 사실 여러분에게 완벽한 선견지명이 있다면, 처음 코딩할 때 작성했어야 하는 테스트다. 회귀 테스트를 작성할 때는 이 테스트를 작성해야 한다는 사실을 어떻게 하면 애초에 알 수 있었을지 항상 생각해보라.

전체 애플리케이션 차원에서 테스트를 수행하는 것에서도 가치를 얻을 수 있다. 애플리케이션 차원의 회귀 테스트는 시스템의 사용자들이 여러분에게 정확히 무엇을 기대했으며 무엇이 잘못되었는지 말할 기회를 준다. 좀더 작은 차원에서의 회귀 테스트는 당신의 테스트를 개선하는 방법이된다. 기괴할 정도로 큰 음수에 대한 결함보고서가 있을 수 있다. 여기에서는 테스트 목록을 작성할 때 정수 롤오버를 테스트할 필요가 있다는 것을 배울 수 있다. 

시스템 장애를 손쉽게 격리시킬 수 없다면 리팩토링해야 한다. 이러한 종류의 장애가 있다는 것은, 시스템의 설계가 아직 끝나지 않았다는 뜻이다.

 

| 휴식 | 

지치고 고난에 빠졌을 땐 뭘 해야 하나? 그럴 땐 좀 쉬는게 좋다.

당신이 결정한 것에 대한 감정적 책임과 당신이 타이핑해 넣은 문자들을 손에서 깨끗이 씻어 버리도록 하라. 종종 이 정도의 거리 두기를 통해 당신에게 부족했던 아이디어가 튀어나올 수 있다. 다음과 같은 생각이 들면 여러분은 벌떡 일어날 것이다 "매개 변수를 뒤집은 상태에서 시도한 적은 없었지!" 어찌됐건간에 좀 휴식을 취하라. 만약 그러한 아이디어를 얻지 못한다면, 현재 세션의 목적을 다시 검토해 보라. 여전히 현실적인가, 아니면 새로운 목적을 골라야 하는가? 당신이 이루려고 노력했던 것이 불가능한 건 아닌가? 만약 그렇다면 팀에는 어떤 의미가 있나? 

데이브 웅가(Dave Ungar)는 이걸 샤워 방법론이라고 부른다. 키보드로 뭘 쳐야 할지 알면, 그걸 치면 된다. 뭘 해야 할지 모르겠으면 샤워하러 가서 뭘 해야 할지 생각날 때까지 계속 샤워를 한다. 그의 방법론을 따른다면 많은 팀들이 더 행복해질 것이고 생산성도 향상될 것이며 냄새도 훨씬 덜 날것이다. TDD는 웅가의 샤워 방법론을 정제한 것이다. 키보드로 뭘 쳐야 할지 알면, 명백한 구현을 한다. 잘 모르겠다면 가짜 구현을 한다. 올바른 설계가 명확하지 않다면 삼각측량 기법을 사용한다. 그래도 모르겠다면 샤워나 하러 가는 거다. 

| 다시 하기 |

길을 잃은 느낌이 들 땐 어떻게 할까? 코드를 다 지워버리고 처음부터 다시 해보자

한 시간 전까지만 해도 잘 돌던 코드가 뒤죽박죽이 됐다. 다음 테스트를 어떻게 통과시켜야 할지도 모르겠고, 앞으로 20개나 되는 테스트를 다 구현해야 한다. 이런일은 자주 있다. 내 본능적인 반응은 꼬인 코드를 계속 진행할 수 있을만큼만 풀어놓자는 것이다. 숙고해보기 위해 잠깐 멈추어 생각해보면, 처음부터 다시 하는 게 항상 더 합리적이라는 결론이 났다. 

 

| 싸구려 책상, 좋은 의자 |

TDD를 할 때 어떤 물리적 환경이 적절한가? 나머지 시설은 싸구려를 쓸지라도 정말 좋은 의자를 구해라. 

허리가 아프면 프로그램을 잘 짤 수가 없다. 

 


27장. 테스팅 패턴

 

| 자식 테스트 | 

지나치게 큰 테스트 케이스를 어떻게 돌아가도록 할 수 있을까? 원래 테스트 케이스의 깨지는 부분에 해당하는 작은 테스트 케이스를 작성하고 그 작은 테스트 케이스가 실행되도록 하라. 그 후에 다시 원래의 큰 테스트 케이스를 추가하라

빨강/초록/리팩토링 리듬은 성공이 지속되는 데 너무나도 중요해서, 그 리듬을 잃어버릴 것 같은 위기 순간에 부가의 노력으로 리듬을 유지하는 것이 충분히 가치 있다. 나의 경우, 테스트를 만들어놓고 보니 막상 이걸 통과시키려면 몇 가지를 한번에 수정해야만 하는 때에 이런 위기의 순간이 생기는데, 단 10분간만 빨간색이 지속되어도 겁이 난다. 

나는 큰 테스트를 작성하고 나면 우선 교훈을 찾기 위해 노력한다. 왜 그렇게 테스트가 컸을까? 어떤 다른 방식을 취했더라면 좀더 작게 만들 수 있었을까? 

나 자신을 형이상학적으로 바라볼 수 있게 되면, 거슬리는 테스트를 삭제하고 다시 시작한다. 때로는 진짜로 테스트를 지워버리기도하고, 또는 메서드 이름 앞에 x를 추가해서 실행만되지 않게 하기도 한다. 두 가지 모두 시도해 보기 바란다. 테스트 두 개가 깨진 상황에서는 어떤 다른 느낌이 드는지, 스스로 어떤 다른 방식으로 코딩하는지 관찰해보고 적절한 방식을 선택하라. 

 

| 모의 객체 |

비용이 많이 들거나 복잡한 리소스에 의존하는 객체를 테스트하려면 어떻게 해야 할까? 상수를 반환하게끔 만든 속임수 버전의 리소스를 만atabase들면 된다

모의 객체(Mock Object)에 대해서는 최소한 책 한 권 분량 정도의 자료가 있지만, 이 책에서는 간단한 소개 정도만 하고자 한다. 고전적인 예는 데이터베이스다. 데이터베이스는 시작 시간이 오래 걸리고, 깨끗한 상태로 유지하기가 어렵다. 그리고 만약 데이터베이스가 원격 서버에 있다면 이로 인해 테스트 성공 여부가 네트워크 상의 물리적 위치에 영향을 받게된다. 또한 데이터베이스는 개발 중 많은 오류의 원인이 된다.

해법은 대부분의 경우에 진짜 데이터베이스를 사용하지 않는 것이다. 대다수의 테스트는, 마치 데이터베이스인 것처럼 행동하지만 실제로는 메모리에만 존재하는 객체를 통해 작성될 수 있다.

public void testOrderLookup() {
    Database db = new MockDatabase();
    db.expectQuery("select order_no from Order where cust_no is 123");
    db.returnResult(new String[] {"Order 2", "Order 3"});
    ...
}

 

MockDatabase는 예상된 쿼리를 얻지 못하면 예외를 던질 것이다. 만약 쿼리가 올바르다면 MockDatabase는 상수 문자열에서 마치 결과 집합(result set)처럼 보이는 뭔가를 생성하여 반환한다. 

성능과 견고함 이외에 모의 객체의 또 다른 가치는 가독성에 있다. 당신은 앞서 나온 테스트를 끝에서 끝까지 읽을 수 있다. 만약 사실적인 데이터로 가득 찬 테스트 데이터베이스를 사용한다면, 어떤 쿼리가 결과 14개를 되돌려야 한다고 적은 테스트를 보더라도 도대체 왜 14개가 올바른 답인지 알기가 쉽지 않다. 

만약 모의 객체를 사용하길 원한다면, 값비싼 자원을 전역 변수에 손쉽게 저장해 버릴 수는 없다. 만약 그렇게 한다면, 전역 변수를 모의 객체로 설정하고, 테스트를 실행한 후 다시 전역 변수를 복구시켜 놓아야 한다.

모의 객체는 당신이 모든 객체의 가시성(visibility)에 대해 고민하도록 격려해서, 설계에서 커플링이 감소하도록 한다. 모의 객체를 사용하면 프로젝트에 위험 요소가 하나 추가된다. 모의 객체가 진짜 객체와 동일하게 동작하지 않으면 어떻게 될까? 모의 객체용 테스트 집합을 진짜 객체가 사용 가능해질 때 그대로 적용해서 이러한 위험을 줄일 수 있다. 

 

| 셀프 션트(self shunt) |

한 객체가 다른 객체와 올바르게 대화하는지 테스트하려면 어떻게 할까? 테스트 대상이 되는 객체가 원래의 대화 상대가 아니라 테스트 케이스와 대화하도록 만들면 된다.

테스팅 사용자 인터페이스의 초록 막대를 동적으로 업데이트하고자하는 상황을 가정해 보자. UI 객체를 TestResult와 연결할 수 있다면 테스트가 실행된 시점, 테스트가 실패한 시점, 전체 테스트 슈트가 시작되고 끝난 시점 등을 통보 받을 수 있을 것이다. 그리고 이를 위한 테스트는 다음과 같을 것이다. 

ResultListenerTest

def testNotification(self):
    result = TestResult()
    listener = ResultListener()
    result.addListener(listener)
    WasRun("testMethod").run(result)
    assert 1 == listener.count

이 테스트가 수행되려면 이벤트 통보 횟수를 셀 객체가 필요하다.

ResultListener

class ResultListener:
    def__init__(self):
        self.count= 0
    def startTest(self):
        self.count= self.count + 1

그런데 왜 이벤트 리스너를 위해 별도의 객체를 만들어야 하는 걸까? 그냥 테스트 케이스 자체를 리스너로 쓰면 될 텐데 말이다. 즉 테스트 케이스가 일종의 모의 객체 노릇을 하는 것이다. 

ResultListenerTest

def testNotification(self):
    self.count = 0
    result= TestResult()
    result.addListener(self)
    WasRun("testMethod").run(result)
    assert 1 == self.count
def startTest(self):
    self.count= self.count + 1

 

셀프 션트 패턴을 이용해 작성한 테스트가 그렇지 않은 테스트보다 읽기에 더 수월하다. 위의 테스트가 좋은 예다. 두 번째 버전의 테스트 메서드는 통보 횟수에 대한 두 값이 한곳에 모여 있는 반면, 첫 번째 버전의 테스트 메서드에서는 하나의 클래스에서 횟수를 0으로 설정하고 다른 클래스에서 1이 예상치임을 나타낸다. 

셀프 션트 패턴은 테스트 케이스가 구현할 인터페이스를 얻기 위해 인터페이스 추출(Extract Interface)을 해야 한다. (인터페이스를 추출하는 것이 더 쉬운지, 존재하는 클래스를 블랙 박스로 테스트하는 것이 더 쉬운지는 사용자의 몫이다) 셀프션트를 위해 추출해 낸 인터페이스는 여러 곳에서 쓰이는 경우가 많다. 

자바의 경우, 셀프 션트를 사용한 결과로 인터페이스 안의 온갖 기괴한 메서드들을 다 구현한 테스트들을 보게 될 것이다. 낙관적 타입 시스템을 가진 언어(동적인 타입 검사를 수행하는 언어들)에서는 테스트 케이스 클래스가 실제로 테스트를 수행하는데 꼭 필요한 오퍼레이션들만 구현하면 된다. 하지만 자바에서는 빈 메서드라도 인터페이스의 모든 오퍼레이션들을 구현해야 한다. 그러므로 가능한 한 인터페이스를 작게 만들길 원할 것이다. 인터페이스에 대한 구현은 또한 적절한 값을 되돌리거나 부적절한 오퍼레이션이 호출된 경우 예외를 던지게끔 만들어야 할 것이다. 

 

| 로그 문자열 | 

메시지의 호출 순서가 올바른지를 검사하려면 어떻게 해야 할까? 로그 문자열을 가지고 있다가 메시지가 호출될 때마다 그 문자열에 추가하도록 한다. 

xUnit에서 쓴 예제를 사용할 수 있다. setUp(), 테스트를 수행하는 메서드, tearDown() 순서로 호출되길 원하는 템플릿 메서드(Template Method)가 있다. 각 메서드들이 로그 문자열에 자기 이름을 추가하게 구현하면 쉽게 읽히는 테스트를 만들 수 있다. 

def testTemplateMethod(self):
    test= WasRun("testMethod")
    result= TestResult()
    test.run(result)
    assert("setUp testMethod tearDown " == test.log)

구현 또한 간단하다. 

WasRun

def setUp(self):
    self.log= "setUp "
def testMethod(self):
    self.log= self.log + "testMethod "
def tearDown(self):
    self.log= self.log + "tearDown "

로그 문자열은 특히 옵저버(Observer)를 구현하고, 이벤트 통보가 원하는 순서대로 발생하는지를 확인하고자 할 때 유용하다. 만약 어떤 이벤트 통보들이 일어나는지를 검사하기는 하지만 그 순서는 상관이 없다면 문자열 집합을 저장하고 있다가 단언(assertion)에서 집합 비교를 수행하면 된다. 

로그 문자열은 셀프 션트와도 잘 작동한다. 해당 테스트 케이스는 로그를 추가하고 적절한 값을 반환하는 식으로 셀프 션트한 인터페이스의 메서드를 구현한다. 

 

| 크래시 테스트 더미 | 

호출되지 않을 것 같은 에러 코드(발생하기 힘든 에러 상황)를 어떻게 테스트할 것인가? 실제 작업을 수행하는 대신 그냥 예외를 발생시키기만 하는 특수한 객체를 만들어서 이를 호출한다.

테스트되지 않은 코드는 작동하는 것이 아니다. 이것이 안전한 가정 같다. 그렇다면 수많은 에러 상황에 대해서는 어떻게 테스트할 것인가? --> 작동하길 원하는 부분에 대해서만 하면 된다. 

파일 시스템에 여유 공간이 없을 경우 발생할 문제에 대해 테스트하기를 원한다고 생각해보자. 1)실제로 큰 파일을 많이 만들어서 파일 시스템을 꽉 채울 수도 있고, 2)가짜 구현(fake it)을 사용할 수도 있다. 

그냥 시뮬레이션한다고 가정하자. 파일을 위한 크래시 테스트 더미(Crash Test Dummy)는 다음과 같다. 

private class FullFile extends File {
    public FullFile(String path) {
        super(path);
    }
    
    public boolean createNewFile() throws IOException {
        throw new IOException();
    }
}

이제 다음과 같은 테스트를 할 수 있다. 

public void testFileSystemError() {
    File f = new FullFile("foo");
    try {
        saveAs(f);
        fail();
    } catch (IOException e) {
    }
}

 

객체 전체를 흉내낼 필요가 없다는 점을 제외하면 크래시 테스트 더미는 모의 객체와 유사하다. 자바의 익명 클래스(anonymous innerclass)는 우리가 테스트하기 원하는 적절한 메서드만이 오류를 발생시키게끔 하기 위해 유용하게 쓰인다. 테스트 케이스 안에서 원하는 메서드 하나만 재정의할 수 있다. 이렇게 하면 테스트 읽기가 수월해진다. 

public void testFileSystemError() {
    File f = new File("foo") {
        public boolean createNewFile() throws IOException {
            throw new IOException();
        }
    };
    try {
        saveAs(f);
        fail();
    } catch (IOException e) {
    }
}

 

| 깨진 테스트 |

혼자서 프로그래밍할 때 프로그래밍 세션을 어떤 상태로 끝마치는게 좋을까? 마지막 테스트가 깨진 상태로 끝마치는 것이 좋다.

프로그래밍 세션을 끝낼 때 테스트 케이스를 작성하고 이것이 실패하는 것을 확실히 확인하는 것이다. 나중에 다시 코딩하기위해 돌아왔을 때, 어느 작업부터 시작할 것인지 명백히 알 수 있다. 전에하고 있던 생각에 대한 명확하고 구체적인 책갈피를 가지게 되는 것이다. 깨진 테스트가 하나가 있다고 해서 프로그램 완성도가 더 떨어지는 것은 아니며, 단지 프로그램의 상태를 드러나게 해줄 뿐이다.

 

| 깨끗한 체크인 |

팀 프로그래밍을 할 때 프로그래밍 세션을 어떤 상태로 끝마치는 게 좋을까? 모든 테스트가 성공한 상태로 끝마치는 것이 좋다. 

같은 팀원들과 함께 작업하는 경우라면 상황은 완전히 달라진다. 팀 프로젝트에서 프로그래밍 세션을 시작하는 경우라면 자신이 마지막으로 코딩한 다음부터 지금까지 무슨 일이 있었는지 세밀하게 알 수 없다. 안심이되고 확신이 서는 상태에서 시작할 필요가 있다. 따라서 코드를 체크인하기 전에 항상 모든 테스트가 돌아가는 상태로 만들어 두어야 한다

체크인하기 전에 실행하는 테스트 스위트(test suite)는 작업 중에 분 단위로 실행하는 테스트 슈트보다 더 클 것이다. 때론 통합 테스트 슈트에서 테스트가 실패하는 경우도 있을 것이다. 그럴 땐 어떻게 해야 할까? 

가장 단순한 규칙은 그동안 작업한 코드를 날려버리고 다시 하는 것이다. 실패한 테스트는 방금 만들어낸 프로그램을 완전히 이해하지 못했다는 강력한 증거이기 때문이다. 만약 전체 팀원이 규칙을 따른다면 체크인을 더 자주하려는 경향이 생길 것이다. 왜냐하면 제일 먼저 체크인하는 사람은 작업을 날릴 위험이 없을 테니까. 체크인을 자주하는 것은 아마 좋은 일일 것이다. 

이 방법보다 아주 약간 방탕해 보이는 접근(원문은 뭐라고 적혀있었을까..?)은, 문제를 수정하고 테스트를 다시 실행해보는 것이다. 통합 자원을 독차지하는 것을 피하기 위해, 아마도 몇 분 후에는 그냥 포기해버리고 다시 시작해야 할 것이다. 말할 필요도 없지만, 테스트 슈트를 통과시키기 위해 주석 처리 하는 것은 금지되는 것이다. 

 

728x90