본문 바로가기

TDD

TDD 예제(점진적으로 설계하기)

TDD의 아이러니 중 하나는 TDD가 테스트 기술이 아니라는 점이다(워드 커닝엄의 선문답이다). TDD는 분석 기술이며, 설계 기술이기도 하다. 사실은 개발의 모든 활동을 구조화하는 기술이다.

- 《테스트 주도 개발》by 켄트 벡


나는 TDD가 현업에서도 쓸만한 방법이란걸 알려주고 싶었다. 그래서 업무와 비슷한 예제를 찾고, TDD로 구현하면 좋겠다는 생각을 했다. 문제는 쓸만한 예제를 찾는 일이었다.


현업과 비슷한 예제를 찾기 위해 두 가지 가정을 했다.

첫째, 대부분의 개발자는 신규 프로젝트를 하지 않는다. 기존 코드(Base Code)에 기능을 추가하거나 빼고, 버그를 잡는일을 한다. 나는 기존 코드에 새 기능을 추가하는 예제를 원했다.

둘째, 너무 쉬운 예제는 안된다. 피보나치 수열은 TDD 단골 예제이다. 피보나치 수열은 문제가 단순하기 때문에 TDD로 구현하기 쉽다. 현업의 복잡한 문제들을 생각하면 이렇게 단순한(그리고 이미 해법이 알려진) 예제는 쓸만하지 않았다.


고민하던 중 적당한 예제가 생각났다. 켄트 벡이 《테스트 주도 개발에서 2부 예제로 만들다가 만 xUnit코드가 있다. 이 코드를 이어서 개발하면, 내가 하고 싶은 이야기를 할 수 있겠다는 생각이 들었다. 예제 코드에 대한 설명은 따로 하지 않겠다. 이 글의 독자는 테스트 주도 개발을 읽은 사람이라고 가정한다.


아래는 켄트 벡이 남긴 작업 목록이다. 이 포스트에선 "테스트 슈트 자동으로 만들기"기능을 구현할 생각이다. 시작해보자.

  • 테스트 메서드 호출하기
  • 먼저 setUp 호출하기
  • 나중에 tearDown 호출하기
  • 테스트 메서드가 실패하더라도 tearDown 호출하기
  • 테스트 여러 개 실행하기
  • 수집한 결과를 출력하기
  • WasRun에 로그 문자열 남기기
  • 실패한 테스트 보고하기
  • setUp 에러를 잡아서 보고하기
  • TestCase 클래스에서 TestSuite 생성하기



1. 테스트 추가하기: 인터페이스 설계하기

테스트를 작성할 때는 오퍼레이션의 완벽한 인터페이스에 대해 상상해보는 것이 좋다. 우리는 지금 오퍼레이션이 외부에서 어떤 식으로 보일지에 대한 이야기를 테스트에 적고 있는 것이다.

- 《테스트 주도 개발》 by 켄트 벡


나는 ‘단위 테스트’라는 말보다 ‘개발자 테스트’라는 말을 좋아한다. QA에서 쓰는 테스트 용어와 구분하기 위해서이다. 내가 생각하는 개발자 테스트의 목표는 세 가지이다.

회귀 테스트 

 코드가 제대로 돌아가는지 확인한다.

샘플(예제) 코드 

 다른 사람에게 API사용법을 알린다.

인터페이스 설계 

 클래스 이름, 메쏘드 위치, 파라미터 개수를 정한다.


TDD에서 구현코드보다 테스트를 먼저 만드는 이유가 뭘까? 그 이유는 '인터페이스 설계' 때문이다. 나머지 두 가지 목표는 덤으로 얻는 가치이다. 테스트 주도 개발자는 테스트를 만들면서 새 기능을 어떻게 사용할지 결정한다. 기능을 사용하는 데 얼마나 많은 객체가 필요한지, 어떤 객체에게 메시지를 보내야하는지, 어떤 데이터가 필요한지 등을 고민한다. 그러다가 적당한 아이디어가 생각나면 테스트로 만들어서 눈으로 확인한다. 테스트는 짧고 단순할수록 좋다. 복잡한 테스트는 인터페이스를 단순하게 만들라는 신호이다. 만약 테스트를 만드는데 7줄 이상의 코드가 필요하다면 더 단순한 인터페이스를 찾아야한다.


테스트를 쉽게 만드는 방법은 '기능을 사용하는 단순한 사례'를 생각하는 것이다. 샘플코드(기능의 사용법을 알리는 코드)를 만든다고 생각하면 쉽다. 이 방법은 김창준이 테스트 주도 개발에서 말한 '예에 의한 사고(예를 들어 생각하기)'기법이다. 아래 세 가지 질문이 도움이 될 것이다.

입력 데이터 

 적당한 입력 값은 무엇인가?

 출력 데이터 

 적당한 출력 값은 무엇인가?

 인터페이스 

 어떤 방식으로 사용할 것인가?


위 질문을 우리가 만들려는 기능에 적용해보자.

  기능: "TestCase로 TestSuite 만들기"

입력 데이터 

 WasRun으로 슈트를 만들 생각이다.

출력 데이터 

 TestResult.summary()값이 "2 run, 1 failed"가 나오면 된다.

인터페이스 

 두 가지 방법이 생각난다(많을수록 좋다).


WasRun으로 슈트를 만드는 방법은 두 가지이다. 생각난 순서대로 써보면 이렇다.


두 인터페이스 중 하나를 골라야한다. 자신의 코딩스타일을 기준으로 선택해보자.

나는 생성자로 테스트를 만들 생각이다. 팩토리 메쏘드가 더 유연한 인터페이스지만, 유연성이 지금 당장 필요한건 아니다. 일단 단순한 인터페이스로 시작하는 것이 좋다.


우선, 켄트 벡이 만든 TestCaseTest.testSuite()메쏘드를 보자.

이 테스트는 슈트를 '수동'으로 만드는 방법을 알려준다.


위 테스트를 고쳐서 슈트를 '자동'으로 만들어보자.


그리고 돌려보면, 실패한다.



2. 테스트 통과시키기: 가짜로 구현하기

테스트를 통과시키는 방법은 세 가지다.

 - 명백한 구현

 - 가짜로 구현하기

 - 삼각측량


TDD를 마스터하고 싶으면 '가짜로 구현하기'를 많이 사용해라. 처음엔 '가짜로 구현하기'가 바보 같아 보일 수 있다. 그래도 '가짜로 구현하기'와 '작은 단계'로 리팩토링하는 방법을 연습해라. 그러면 오버엔지니어링을 피할 수 있다. 현재 요구사항에 꼭 맞는 설계를 만들 수 있다. 이 포스트에서는 '가짜로 구현하기'와 '작은 단계'로 리팩토링 하는 전략을 쓰겠다. 바보 같은 구현으로 테스트를 통과시켜도 화내지 말고, 조금만 참아라.


일단 테스트를 통과시켜야 한다. 지금은 컴파일 에러가 있다. 현재 테스트에서 TestSuite생성자에 파라미터를 넣어 주는데, TestSuite는 파라미터를 받지 않는다.


우선 TestSuite생성자에 파라미터를 하나 추가하자. 파라미터 이름은 testClass로 하자.


그리고 돌려보자.


이상한 에러가 생겼다. 새로운 에러의 원인은 실행 코드에 있다. 실행 코드에서 TestSuite를 만들 때 파라미터를 전달하지 않는다.


이 에러를 잡는 가장 단순한 방법이 뭘까?

가장 먼저 떠오른 아이디어는 메쏘드 오버로딩이다. 파라미터가 없는 생성자와 파라미터를 받는 생성자 두 개를 만드는 것이다. 하지만 파이썬은 메쏘드 오버로딩을 지원하지 않아서 쓸 수 없다.

다른 방법을 찾아보니 '기본 파라미터 값'라는 기능이 있다. 이걸 쓰면 오버로딩을 흉내 낼 수 있다고 한다. 이 기능을 사용하자.

testClass의 '기본 파라미터 값'으로 None을 넣어주자. 그리고 돌려보자.


컴파일 에러는 잡았지만, 테스트는 계속 실패하고 있다.


테스트를 통과 시키는 단순한 방법이 뭘까?
하드코딩으로 슈트를 만들어주면 된다. 이전 테스트에서 봤던 코드를 그대로 붙여넣기 하자(조건문을 하나 추가해서 testClass가 WasRun일 때만 돌아가도록 만들자).


그리고 돌려보면 성공한다.



3. 중복코드 제거하기: 구현 코드 설계하기

'가짜로 구현하기'를 마스터하려면 중복을 찾는 훈련을 해야 한다.

TDD에서 중복은 두 가지 형태로 나타난다. 

1. '코드'와 '코드'사이의 중복

2. '테스트'와 '코드'사이의 중복


여기에선 코드와 코드 사이에 중복이 있다.

코드에서 'testMethod'라는 글자가 두 번 나온다. TestSuite에서 '문자열'로 한 번 나오고, WasRun에서 '메쏘드 이름'으로 한 번 나온다. 중복이다.


이 중복만 없애면 이번 리팩토링이 끝난다.

당장 생각나는 해결책은 리플렉션을 쓰는 것이다. 리플렉션을 쓰면 WasRun의 메쏘드 리스트를 동적으로 찾을 수 있다. 이 구현으로 하드코딩 된 생성자를 없앨 수 있다.

하지만 이 방법은 단계가 너무 크다(구현하기까지 조금 오래걸린다)단계가 크면 중간에 실수할 가능성이 높아지고, 어디에서 실수를 했는지 바로 알 수 없다. 작은 단계로 중복을 조금씩 없애보자.


위 코드에서 5번과 6번 코드를 보면 add()메쏘드가 두 번 나온다. 문자열을 리스트에 넣고 for문으로 돌리면 add()메쏘드를 한 줄로 만들 수 있다.

우선 리스트를 하나 추가하자.


돌려보면 돌아간다. 아직 고친부분이 없으니 당연하다. 이제 추가한 리스트를 이용해서 6번 코드를 고치자.


돌려보면 돌아간다. 7번 코드도 같은 방식으로 고치자.


돌아간다. names의 스펠링이 틀리지 않은 것 같다.

우리는 for문이 자동으로 하는 일을 하드코딩으로 구현했다. for문을 써서 하드코딩을 없애버리자.


돌아간다. 

이제 리스트만 동적으로 얻을 수 있다면 'TestSuite'와 'WasRun'사이의 중복을 없앨 수 있다.

Java에서는 리플렉션을 쓰면 메쏘드 리스트를 알아낼 수 있다. 파이썬에 비슷한 기능이 있는지 찾아보자.


API문서를 찾아보면 dir()이라는 함수가 있다. 이 함수는 파라미터의 모든 맴버('필드' + '메쏘드')를 리스트로 반환한다. '필드'리스트는 필요 없지만, 다른 함수가 없기 때문에 이걸 써야겠다. dir()함수를 써서 names에 새 리스트를 넣어주자.


돌려보면 실패한다.

실패하는 이유는 dir()함수가 상속한 클래스의 멤버까지 찾아내기 때문이다. 코드에서 WasRun은 TestCase를 상속한다. 또, TestCase는 object(최상위 클레스)를 상속한다. 결국 dir(WasRun)함수는 세 클레스(WasRun, TestCase, object)모든 멤버를 반환한다. 테스트를 통과시키려면 필요없는 맴버를 걸러내야한다.


리스트에서 특정 원소만 뽑아내는 기능이 있으면 좋겠다. API문서를 찾아보면 filter()라는 함수가 있다. filter()함수는 두 개의 파라미터를 받는다. 람다와 리스트이다. 람다에 원하는 조건식을 써서 filter()함수에 전해주면, filter()함수는 조건에 맞는 원소만 찾아서 새 리스트로 반환한다.


내가 원하는 원소는 이름이 'test'로 시작하는 메쏘드이다. fileter()함수와 람다를 써서 고드를 고치자.


돌려보면 잘 돌아간다. 5번 코드는 이제 필요 없다. 지워버리자.


돌아간다. 

다른 중복을 찾아보자. 5번과 7번 코드를 보면 WasRun이 두 번 나온다. 이걸 없애는 가장 단순한 방법이 뭘까?


현재 조건문에서 testClass는 항상 WasRun이다. 조건식이 이것을 보장한다. testClass를 쓰면 중복을 없앨 수 있을 것 같다. 실험해보자.

우선, 5번 코드의 WasRun을 testClass로 바꿔보자. 그리고 돌려보자.


돌아간다. 다행이다. 7번 코드도 WasRun을 testClass로 바꾸자.


돌아간다. 이제 코드에 WasRun에 대한 내용이 없다. 충분히 추상화를 한 것 같다. 이 코드로 다른 TestCase의 슈트를 만들 수 있을 것 같다. 가능할까? 실험해보자.

실행코드를 보면 '수동'으로 TestCaseTest의 슈트를 만드는 코드가 있다.


위 코드를 고쳐서 '자동'으로 슈트를 만들자.


그리고 돌려보자.


실패한다. 원하던 결과가 아니다. '0 run, 0 failed'가 아닌 '5 run, 0 failed'가 나와야 한다. TestSuite생성자를 고쳐보자(조건문을 추가해서 testClass가 TestCaseTest일 때만 돌아가도록 만들자).


돌아간다. 두 조건문의 코드가 똑같다. 중복이다. 조건문을 지우고, 하나의 로직으로 만들자.


돌아간다. 이제 '기본 파리미터 값'을 쓰는 코드가 없다. 지워보자.


돌아간다. 코드 추상화가 끝났다. 모든 슈트를 자동으로 만들 수 있다.

추상화는 끝났지만, 리팩토링은 조금 더 해야 한다. 

4, 5, 6번은 서로 깊게 연관된 로직이다. 하나로 묶어버리자.


돌아간다. 7번 코드는 다른 곳 보다 복잡하다. 메쏘드로 뽑아내서 단순화해야겠다.

이 코드는 정적 메쏘드로 뽑아내자. 7번 코드는 인스턴스 멤버를 쓰지 않기 때문에 인스턴스 메쏘드나 정적 메쏘드로 뽑아낼 수 있다. 내 기준엔 정적 메쏘드가 더 단순하다. 


돌아간다. 이제 복잡한 filter()함수를 정리하자.

우선, 람다에 이름을 붙여주자.


돌아간다. 메쏘드 이름을 생각하면 each를 name으로 바꾸는 게 좋겠다.


돌아간다. dir(testClass)도 이름을 붙여서 균형을 맞추자.


돌아간다. 됐다. 이제 addTests()메소드로 되돌아가자.


3번 코드는 인라인 시키자. 그래도 코드 읽기는 불편하지 않을 것 같다.


돌아간다. 모든 리팩토링이 끝났다. 새 기능을 추가했고, 잘 돌아가도록 구현했다. 그리고 올바른 자리로 옮겨줬다. 할 일을 끝냈으니 목록에서 지울 수 있다.

 

그런데 걱정거리가 하나 있다. 현재 코드는 test로 시작하는 메쏘드만 있다면 슈트를 만들 수 있다. 꼭 TestCase를 상속하지 않아도 슈트를 만들 수 있다.

이걸 막아야하나? 아직 모르겠다. 주변에 기획자가 있다면 물어보고 싶다. 일단 목록에 적어놓자. 나중에 xUnit을 어떻게 사용할지 정리되면 처리하자.


  • 테스트 메서드 호출하기
  • 먼저 setUp 호출하기
  • 나중에 tearDown 호출하기
  • 테스트 메서드가 실패하더라도 tearDown 호출하기
  • 테스트 여러 개 실행하기
  • 수집한 결과를 출력하기
  • WasRun에 로그 문자열 남기기
  • 실패한 테스트 보고하기
  • setUp 에러를 잡아서 보고하기
  • TestCase 클래스에서 TestSuite 생성하기 
  • 컴파일에러와 테스트실패를 구분하기
  • object 클래스에서 TestSuite 생성하기(?) #새 테스트 추가


오늘 만든 코드는 이렇다.



마무리

사전 설계

         설  계      ->      구  현      ->       테 스 트

T D D

(점진적인 설계)

         테 스 트     ->      구  현      ->      리팩토링

    (인터페이스 설계)                          (구현코드 설계)


[설계를 언제 해야하나?]

'설계하기'란 결과물의 구조를 결정하는 일을 말한다. 건축가에게 설계하기란 건물구조를 결정하는 일을 말하고, 작가에게 설계하기란 글의 구조를 결정하는 일을 말하고, 프로그래머에게 설계하기란 코드 구조를 결정하는 일을 말한다.


전통적으로 소프트웨어 설계는 건축 설계를 모방해왔다. 하지만 건축에서 효과적인 방식은 소프트웨어에서는 통하지 않았다. 두 제품이 다른 세계의 제품이기 때문이다. 건물은 물리세계 제품이다. 물리세계 제품은 '구현하기 전'과 '구현한 후'의 설계 수정비용 차이가 크다. 예를 들어 화장실 크기를 넓히고 싶다 치자. 구현하기 전에는 거의 공짜로 고칠 수 있다. 지우개와 연필만 있으면 화장실 크기를 바꿀 수 있다. 반면 구현한 후에는 많은 돈을 써야 고칠 수 있다. 기존 화장실 벽을 부수고, 새로운 벽을 세워야하기 때문에 돈이 많이 든다. 이런 이유로 물리세계 제품은 '구현하기 전에 설계'하는 전략을 사용한다.


가상세계 제품은 '구현한 후 설계'하는 전략을 사용한다. 글, 음악, 그림은 대표적인 가상세계 제품이다. 이 중에서 글을 쓰는 작가들의 작업방식을 살펴보자. 작가이자 글쓰기 선생인 윌리엄 진서는 《글쓰기 생각쓰기》에서 "좋은 글쓰기의 핵심은 고쳐쓰기"라고 말한다. 또 "고쳐쓰기는 부끄러운 일이 아니다"라고 말한다. 대부분의 작가들은 고쳐쓰기를 적극적으로 활용한다. 작가들도 건축가들처럼 '구현하기 전에 설계'를 하긴 하지만, 완벽한 사전설계를 하지 않는다. 고쳐쓰기로 잘못된 설계를 바로잡을 수 있기 때문에 작가들은 최소한의 사전설계만 한다.

TDD는 작가들의 작업방식과 비슷하다. 일단 돌아가는 코드(초고)를 만들고 코드를 여러 번 리팩토링(고쳐쓰기)한다. TDD의 핵심은 설계시점의 변화에 있다. TDD는 '구현하기 전 설계'보다 '구현한 후 설계'를 적극적으로 이용한다. 



[TDD의 장점]

 + 장점1. 설계 결정에 대한 빠른 피드백

TDD의 또 다른 효과는 설계 결정에 대한 피드백 고리를 단축시킨다는 점이다. (중략) 설계 결정에 대한 피드백 루프의 길이는 설계에 대한 생각(API가 이런 식으로 생기면 좋겠다, 혹은 메타포가 이래야 할 것이다 등)과 그에 대한 첫 번째 예제(그 생각을 담고 있는 테스트 작성) 사이의 간격이다. 설계에 대한 결정을 내리고 기쁨이나 고통을 맛보기까지 몇 주에서 몇 달을 기다리는 대신, 설계 아이디어를 몇 초에서 몇 분 사이에 그럴듯한 인터페이스로 전환하기만 하면 피드백을 받을 수 있다.

- 《테스트 주도 개발》 by 켄트 벡


 + 장점2. 작동하는 소프트웨어

TDD는 작동하는 소프트웨어를 빨리 만든다. 보통 30분 ~ 2시간에 한 번씩 원하는 기능이 포함된 소프트웨어를 만들 수 있다. 스폰서(고객, 사장님)는 소프트웨어를 자주 써보고, 다음 사업 전략을 계획할 수 있다.


사전 설계는 작동하는 소프트웨어를 확인하려면 마지막 테스트까지 기다려야 한다. 보통 몇 주에서 몇 달정도 걸린다. 만들고 보니 스폰서가 원하던 기능이 아닐 가능성이 있다. 게다가 작동하지 않을 수도 있다.