TDD - 테스트 주도 개발(Test Driven Development)

2021. 4. 18. 19:31 테스트 코드/JUnit

 

작성하려는 코드가 있다면 항상 먼저 어떻게 그 코드를 테스트할지 고민해야한다. 코드를 작성한 후에 어떻게 테스트할지 고민하기보다 작성할 코드를 묘사하는 테스트를 설계해야 한다. 이것이 테스트 주도 개발(TDD, Test Driven Development)에 기반을 둔 단위 테스트 전략의 핵심이다.

 

TDD에서 단위 테스트를 시스템의 모양을 잡고 통제하는 도구로 활용해야 한다. 단위 테스트는 종종 잘 선별한 후 한쪽에 치워 놓고 나중에 반영하려는 코드가 될 수 있는데, 단위 테스트는 소프트웨어를 어떻게 만들어야 할지에 관한 잘 훈련된 사이클의 핵심적인 부분이다. 따라서 TDD를 채택하면 소프트웨어 설계는 달라지고, 아마 훨씬 더 좋은 설계의 코드가 될 것이다.

 

TDD의 주된 이익

단위 테스트를 사후에 작성하여 얻을 수 있는 가장 분명하고 명확한 이익은 "코드가 예상한 대로 동작한다는 자신감을 얻는 것" 이다. TDD에서도 역시 동일한 이익과 그 이상을 얻을 수 있다. 

 

코드를 깨끗하게 유지하도록 치열하게 싸우지 않으면 시스템은 점점 퇴화한다. 코드를 재빠르게 추가할 수는 있지만 처음에는 좋은 코드라기보다는 그다지 위대하지 않은 코드일 가능성이 높다. 보통은 개발 초기부터 나쁜 코드를 여러 가지 이유로 정리하지 않고는 한다.

 

TDD에서는 코드가 변경될 것이라는 두려움을 지울 수 있다. 정말로 리팩토링은 위험 부담이 있는 일이기도 하고 우리는 위험해보이지 않는 코드를 변경할 때도 실수를 하곤 한다. 하지만 TDD를 잘 따른다면 구현하는 실질적인 모든 사례에 대해 단위 테스트를 작성하게 된다. 이러한 단위 테스트는 코드를 지속적으로 발전시킬 수 있는 자유를 준다.

 

TDD는 세 부분의 사이클로 구성된다.

  • 실패하는 테스트 코드 작성하기
  • 테스트 통과하기
  • 이전 두 단계에서 추가되거나 변경된 코드 개선하기

첫 번째 단계는 시스템에 추가하고자 하는 동작을 정의하는 테스트 코드를 작성하는 것이다.

 

우리는 이미 이전 포스팅에서 이미 다루어봤던 Profile이라는 클래스를 새로 만들 것이다. 가장 단순한 사례로, Profile 클래스 자체를 만들기 전에 먼저 테스트 코드를 아래와 같이 작성해보자.

 

public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      new Profile();
   }
}

 

Profile이라는 클래스가 존재하지 않기 때문에 컴파일 에러가 발생할 것이다. 또한 IDEA는 Profile이라는 클래스가 없으므로 클래스를 생성해달라 할 것이다. Profile 클래스를 Quick Fix 기능으로 생성하면 컴파일에러는 해결될 것이다. 사실 이러한 작은 테스트는 컴파일되는 것만으로 충분한 테스트가 되기 때문에 굳이 테스트를 실행시킬 필요는 없다.

 

public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1, "Relocation package?");
      Criterion criterion = 
         new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
}
 
public class Profile {
   public boolean matches(Criterion criterion) {
      return true;
   }
}

 

컴파일 에러를 해결하며 위와 같이 작은 테스트 단위로 하나씩 코드를 증가시켜가며 작성해준다. 여기까지 메서드 내부를 세부적으로 구현하지 않고 단순히 true를 리턴하는 메서드를 작성하므로써 실패하는 테스트 코드를 작성하였다. 그리고 테스트 성공을 위해 assertTrue(result)로 변경하여 테스트를 성공시킨다.

 

여기까지 우리는 Profile 클래스의 한 작은 부분을 만들었고 그것이 동작함을 알게되었다. 여기까지 소스를 작성하였다면 Git과 같은 VCS에 커밋할 차례이다. TDD를 하면서 작은 코드를 커밋하는 것은 필요할 때 백업하거나 방향을 반대로 돌리기 수월해진다. 만약 큼지막한 단위로 커밋한다면 롤백은 그만큼 힘든 작업이 된다.

 

다음 작성할 테스트 코드는 Profile이 갖고 있는 Answer 객체와 Criterion이 가지고 있는 Answer 객체를 매칭시키는 테스트이다.

public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1, "Relocation package?");
      Criterion criterion = 
         new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1, "Relocation package?");
      Answer answer = new Answer(question, Bool.TRUE);
      profile.add(answer);
      Criterion criterion = new Criterion(answer, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
}

 

위와 같이 profile.matches()를 테스트 코드로 추가한다. 하지만 matches()는 세부적인 구현이 이루어지지 않은 상태이므로 아래와 같이 Profile에 matches()를 수정한다.

public class Profile {
   private Answer answer;
 
   public boolean matches(Criterion criterion) {
      return answer != null;
   }
 
   public void add(Answer answer) {
      this.answer = answer;
   }
}

 

이제 Profile이 Answer 객체만 가지고 있다면 true를 리턴할 것이다.

 

여기까지 테스트 코드를 작성하였다면 이제는 테스트 코드를 조금은 정리할 필요가 있습니다. 두 개의 테스트만 보더라도 중복되는 코드 라인이 보이기 때문에 @Before 메서드로 중복된 초기화 코드를 분리해줍니다.

public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionAndAnswer() {
      questionIsThereRelocation = 
            new BooleanQuestion(1, "Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
   }
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
}

 

테스트 코드의 리팩토링 과정에서 프로덕 코드의 변경은 없었고 물론 테스트도 통과할 것이다. 하지만 분명한 필드이름과 중복된 코드의 제거로 더욱 깔끔한 테스트코드가 되었다.

 

여기서 자칫하면 "어? 여러개의 @Test 메서드가 동일한 필드들을 공유하는데, 테스트 결과가 잘못 나오는 거 아니야?"라고 질문을 던질 수 있다. 하지만 이전 포스팅에서 얘기 햇듯이 @Test 마다 새로운 ProfileTest 인스턴스를 생성하기 때문에 매 테스트마다 인스턴스 변수는 독립적으로 사용한다.

 

다음 테스트는 Profile 인스턴스가 매칭되는 Answer 객체가 없을 때, matches() 메서드가 false를 반환하는 테스트이다.

public class ProfileTest {
   private Answer answerThereIsNotRelocation;
   // ... 
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionAndAnswer() {
      questionIsThereRelocation = 
            new BooleanQuestion(1, "Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
   }
   // ...
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
   
   @Test
   public void doesNotMatchWhenNoMatchingAnswer() {
      profile.add(answerThereIsNotRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
}

 

테스트가 통과하려면 matches() 메서드는 Profile 객체가 들고 있는 단일 Answer 객체가 Criterion 객체에 저장된 응답과 매칭되는 지 결정해야 한다. Answer 클래스를 보면 어떻게 응답들을 비교하는지 알 수 있다.

public class Answer {
   // ...
   private int i;
   private Question question;
 
   public Answer(Question question, int i) {
      this.question = question;
      this.i = i;
   }
 
   public Answer(Question question, String matchingValue) {
      this.question = question;
      this.i = question.indexOf(matchingValue);
   }
   
   public String getQuestionText() {
      return question.getText();
   }
 
   @Override
   public String toString() {
      return String.format("%s %s", 
         question.getText(), question.getAnswerChoice(i));
   }
 
   public boolean match(int expected) {
      return question.match(expected, i);
   }
 
   public boolean match(Answer otherAnswer) {
      // ...
      return question.match(i, otherAnswer.i);
   }
   // ...
 
   public Question getQuestion() {
      return question;
   }
}

 

이제 match 메서드를 이용하여 테스트를 통과하는 matches() 메서드 내의 단일 조건문을 추가한다.

public class Profile {
   private Answer answer;
 
   public boolean matches(Criterion criterion) {
      return answer != null && 
         answer.match(criterion.getAnswer());
   }
   // ...
 
   public void add(Answer answer) {
      this.answer = answer;
   }
}

 

TDD로 성공하러면 테스트 시나리오를 테스트로 만들고 각 테스트를 통과하게 만드는 코드 증분을 최소화하는 순으로 코드를 작성한다.

 

이제 Profile 클래스가 다수의 Answer 객체를 갖도록 수정할 것이다. 다수의 Answer 객체를 Profile 클래스는 Map으로 갖도록 설계하였다.(key,value)

public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1, "Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1, "Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
   }
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
   
   @Test
   public void doesNotMatchWhenNoMatchingAnswer() {
      profile.add(answerThereIsNotRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
 
   @Test
   public void matchesWhenContainsMultipleAnswers() {
      profile.add(answerThereIsRelocation);
      profile.add(answerDoesNotReimburseTuition);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertTrue(result);
   }
}

 

먼저 Profile이 다수의 Answer 객체를 가지는 테스트 코드를 작성한다.(matchesWhenContainsMultipleAnswers)

public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return answer != null && 
         answer.match(criterion.getAnswer());
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}

 

테스트 통과를 위한 Profile 클래스를 수정한다. 테스트 통과를 위해 matches() 메서드의 일부로 getMatchingProfileAnswer() 메서드를 호출하여 반환값이 null인지 여부를 확인한다. 하지만 이러한 널 체크 구문을 다른 곳으로 숨기고 싶다. 그래서 Answer 클래스의 match() 메서드로 널체크를 보낼 것이다.

 

그렇다면 이전에 matches의 리턴문이 answer.match(criterion.getAnswer()) 였다면 아래와 같이 코드는 수정될 것이다.

public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}

 

또한 Answer의 match()에는 null을 체크하는 구문이 추가된다.

public class Answer {
   private int i;
   private Question question;
 
   ...
 
   public boolean match(Answer otherAnswer) {
      if (otherAnswer == null) return false;
      // ...
      return question.match(i, otherAnswer.i);
   }
 
   ...
}

 

하지만 여기서 끝이 아니다. 바로 위의 Answer를 수정하기 전에 아래와 같은 테스트코드가 작성되어야한다.

public class AnswerTest {
   @Test
   public void matchAgainstNullAnswerReturnsFalse() {
      assertFalse(new Answer(new BooleanQuestion(0, ""), Bool.TRUE)
        .match(null));
   }
}

 

TDD를 할 때 다른 코드를 전혀 건드리지 않고 Profile 클래스만 변경할 필요는 없다. 필요한 사항이 있다면 설계를 변경하여 다른 클래스로 너어가도 된다.(Answer 클래스)

 

이제는 조금 코드를 확장할 것이다. 단일 Criterion 객체로만 매칭하는 것이 아니라 다수의 Criterion 객체를 가지는 Criteria 객체를 인수로 받아 매칭하는 코드로 변화 시킬 것이다.

public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
   private Answer answerReimbursesTuition;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1, "Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1, "Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
      answerReimbursesTuition = 
         new Answer(questionReimbursesTuition, Bool.TRUE);
   }
 
   ...
 
   @Test
   public void doesNotMatchWhenNoneOfMultipleCriteriaMatch() {
      profile.add(answerDoesNotReimburseTuition);
      Criteria criteria = new Criteria();
      criteria.add(new Criterion(answerThereIsRelo, Weight.Important));
      criteria.add(new Criterion(answerReimbursesTuition, Weight.Important));
      
      boolean result = profile.matches(criteria);
      
      assertFalse(result);
   }
}

 

이제는 Profile 객체가 Criteria 객체를 받아 매칭하는 테스트 코드를 작성하였다. 결과를 통과 시키기 위해서 Profile 클래스에 Criteria를 인자로 받는 하드 코딩한 메서드를 추가한다.

public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}

 

그리고 빠르게 다음 테스트를 작성한다. 단순히 true를 리턴하는 메서드에서 Criteria 객체를 순회하며 하나씩 꺼낸 Criterion 객체를 매치하는 메서드로 수정한다.

public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      for (Criterion criterion: criteria)
         if (matches(criterion))
            return true;
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}

 

여기서 Criteria 객체를 로컬 변수로 매번 새롭게 생성하는데, 이것을 @Before 메서드를 활용하여 초기화하는 코드로 변경하면 더 깔끔한 코드가 된다.

 

이제 이 단계에서 여러 특별한 사례를 추가한다. Criterion의 MustMatch에 해당 되는 질문이 Profile 객체가 가지고 있지 않다면 실패하는 테스트 코드를 작성한다.

public class ProfileTest {
   // ...
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
   private Answer answerReimbursesTuition;
   private Criteria criteria;
   
   @Before
   public void createCriteria() {
      criteria = new Criteria();
   }
   // ...
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1, "Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1, "Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
      answerReimbursesTuition = 
         new Answer(questionReimbursesTuition, Bool.TRUE);
   }
 
   ...
 
   
   @Test
   public void doesNotMatchWhenAnyMustMeetCriteriaNotMet() {
      profile.add(answerThereIsRelo);
      profile.add(answerDoesNotReimburseTuition);
      criteria.add(new Criterion(answerThereIsRelo, Weight.Important));
      criteria.add(new Criterion(answerReimbursesTuition, Weight.MustMatch));
      
      assertFalse(profile.matches(criteria));
   }
}
 
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
 
   public boolean matches(Criteria criteria) {
      boolean matches = false;
      for (Criterion criterion: criteria) {
         if (matches(criterion))
            matches = true;
         else if (criterion.getWeight() == Weight.MustMatch)
            return false;
      }
      return matches;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}

 

테스트를 통과시키기 위해 Profile matches 메서드로 들어오고 Profile 객체의 Answer 중 매치되지 않은 질문의 weight이 MustMatch라면 false를 반환하는 코드로 작성한다.(즉, MustMatch는 반드시 매칭되어야 하는 필수조건인 것이다. 나머지가 다 맞고 MustMatch 하나만 안맞아도 매치는 false를 날린다.)

 

이런식으로 TDD를 이용하여 코드를 작성한다. 마지막으로 코드가 완성되었다면 테스트 클래스의 메서드의 이름들은 어떠한 코드의 명세서가 될 수 있다. 오늘 다루어본 TDD는 사실 두서없이 기본만 다루어본 내용이다. 사실 필자로 TDD에 익숙치 않은 개발자이기 때문에 TDD가 무엇인가 정도만 습득했어도 성공이라고 생각했다. 추후에는 실제 웹개발 코드를 작성할때 TDD를 이용한 개발 등의 포스팅을 할 예정이다.

 

사실 노력없는 결실은 없는 것 같다. 꾸준히 TDD로 개발하다보면 언젠간 적응되고 더 좋은 코드를 개발하는 날이 오지 않을까?

 

앞으로 더욱 많은 TDD 관련 포스팅을 할 예정이며, 현재까지 작성된 포스팅은 아래 책을 기반으로 작성하였다.

 



출처: https://coding-start.tistory.com/268?category=814944 [코딩스타트]