TDD - 좋은 단위 테스트 작성하는 방법(FIRST 속성)

2019. 9. 5. 13:25Web/TDD

 

단위 테스트를 작성하는데 있어서 FIRST 속성을 지킨다면 더 좋은 단위테스트를 작성할 수 있다. 그렇다면 FIRST 속성이란 무엇일까?

 

  • Fast : 빠른
  • Isolated : 고립된
  • Repeatable : 반복 가능한
  • Self-validating : 스스로 검증 가능한
  • Timely : 적시의

일반적인 단위 테스트를 작성하던 TDD 개발 방법론을 이용하던 FIRST 원리를 고수하면 더 나은 테스트 코드를 작성하는 데 도움이 된다.

 

1)Fast : 빠르게

테스트 코드를 느린 로직에 의존하지 않고 테스트를 빠르게 유지한다. 조금더 클린한 객체지향 설계에 맞춰 애플리케이션이 설계된다면 조금더 나은 테스트 코드를 작성하기 좋아진다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class StatCompiler {
   static Question q1 = new BooleanQuestion("Tuition reimbursement?");
   static Question q2 = new BooleanQuestion("Relocation package?");
 
   class QuestionController {
      Question find(int id) {
         if (id == 1)
            return q1;
         else
            return q2;
      }
   }
 
   private QuestionController controller = new QuestionController();
 
   public Map<String, Map<Boolean, AtomicInteger>> responsesByQuestion(
         List<BooleanAnswer> answers) {
      Map<Integer, Map<Boolean, AtomicInteger>> responses = new HashMap<>();
      answers.stream().forEach(answer -> incrementHistogram(responses, answer));
      return convertHistogramIdsToText(responses);
   }
 
   private Map<String, Map<Boolean, AtomicInteger>> convertHistogramIdsToText(
         Map<Integer, Map<Boolean, AtomicInteger>> responses) {
      Map<String, Map<Boolean, AtomicInteger>> textResponses = new HashMap<>();
      responses.keySet().stream().forEach(id -> 
         textResponses.put(controller.find(id).getText(), responses.get(id)));
      return textResponses;
   }
 
   private void incrementHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, 
         BooleanAnswer answer) {
      Map<Boolean, AtomicInteger> histogram = 
            getHistogram(responses, answer.getQuestionId());
      histogram.get(Boolean.valueOf(answer.getValue())).getAndIncrement();
   }
 
   private Map<Boolean, AtomicInteger> getHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, int id) {
      Map<Boolean, AtomicInteger> histogram = null;
      if (responses.containsKey(id)) 
         histogram = responses.get(id);
      else {
         histogram = createNewHistogram();
         responses.put(id, histogram);
      }
      return histogram;
   }
 
   private Map<Boolean, AtomicInteger> createNewHistogram() {
      Map<Boolean, AtomicInteger> histogram;
      histogram = new HashMap<>();
      histogram.put(Boolean.FALSE, new AtomicInteger(0));
      histogram.put(Boolean.TRUE, new AtomicInteger(0));
      return histogram;
   }
}
cs

 

위와 같은 코드가 있고 우리는 responsesByQuestion() 이라는 어떠한 메소드(행위)를 테스트 한다고 가정해보자. 해당 메서드는 다른 메서드를 다수 호출하고 있는 어떠한 행위이다. 그중 마지막 convertHistogramIdsToText()를 호출하고 있는데, 해당 메서드는 controller.find(id)로 데이터베이스를 액세스해서 id값에 해당되는 question text 값을 가져오고 있다. 만약 테스트할 개수가 아주 많다면 그만큼 데이터 액세스하는 속도가 줄어버릴 것이다. 그리고 해당 클래스를 테스트하는 것 말고, 다른 테스트 코드도 이렇게 데이터 베이스를 액세스하고 있다면? 전체적인 테스트 속도는 느려질 것이다. 이러한 문제점을 작은 설계 변경으로 해결할 수 있다!

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class StatCompiler {
   private QuestionController controller = new QuestionController();
   
   public Map<Integer,String> questionText(List<BooleanAnswer> answers) {
      Map<Integer,String> questions = new HashMap<>();
      answers.stream().forEach(answer -> {
         if (!questions.containsKey(answer.getQuestionId()))
            questions.put(answer.getQuestionId(), 
               controller.find(answer.getQuestionId()).getText()); });
      return questions;
   }
 
   public Map<String, Map<Boolean, AtomicInteger>> responsesByQuestion(
         List<BooleanAnswer> answers, Map<Integer,String> questions) {
      Map<Integer, Map<Boolean, AtomicInteger>> responses = new HashMap<>();
      answers.stream().forEach(answer -> incrementHistogram(responses, answer));
      return convertHistogramIdsToText(responses, questions);
   }
 
   private Map<String, Map<Boolean, AtomicInteger>> convertHistogramIdsToText(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, 
         Map<Integer,String> questions) {
      Map<String, Map<Boolean, AtomicInteger>> textResponses = new HashMap<>();
      responses.keySet().stream().forEach(id -> 
         textResponses.put(questions.get(id), responses.get(id)));
      return textResponses;
   }
 
   private void incrementHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, BooleanAnswer answer) {
      Map<Boolean, AtomicInteger> histogram = 
            getHistogram(responses, answer.getQuestionId());
      histogram.get(Boolean.valueOf(answer.getValue())).getAndIncrement();
   }
 
   private Map<Boolean, AtomicInteger> getHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, int id) {
      Map<Boolean, AtomicInteger> histogram = null;
      if (responses.containsKey(id)) 
         histogram = responses.get(id);
      else {
         histogram = createNewHistogram();
         responses.put(id, histogram);
      }
      return histogram;
   }
 
   private Map<Boolean, AtomicInteger> createNewHistogram() {
      Map<Boolean, AtomicInteger> histogram;
      histogram = new HashMap<>();
      histogram.put(Boolean.FALSE, new AtomicInteger(0));
      histogram.put(Boolean.TRUE, new AtomicInteger(0));
      return histogram;
   }
}
cs

 

questionText() 메서드가 추가 된 것을 볼 수 있다. BooleanAnswer리스트를 매개변수로 받아서 아이디 값에 대응되는 question text를 가져오는 메서드이다. 그리고 나머지 메서드를 보면 이미 데이터베이스에서 액세스해서 가져온 id대question text 해시맵을 매개변수로 받고 있다. 만약 이렇게 코드 설계를 살짝 변경하였다면 테스트 코드는 어떻게 달라질까?!

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class StatCompilerTest {
   @Test
   public void responsesByQuestionAnswersCountsByQuestionText() {
      StatCompiler stats = new StatCompiler();
      List<BooleanAnswer> answers = new ArrayList<>();
      answers.add(new BooleanAnswer(1true));
      answers.add(new BooleanAnswer(1true));
      answers.add(new BooleanAnswer(1true));
      answers.add(new BooleanAnswer(1false));
      answers.add(new BooleanAnswer(2true));
      answers.add(new BooleanAnswer(2true));
      Map<Integer,String> questions = new HashMap<>();
      questions.put(1"Tuition reimbursement?");
      questions.put(2"Relocation package?");
      
      Map<String, Map<Boolean,AtomicInteger>> responses = 
            stats.responsesByQuestion(answers, questions);
      
      assertThat(responses.get("Tuition reimbursement?").
            get(Boolean.TRUE).get(), equalTo(3));
      assertThat(responses.get("Tuition reimbursement?").
            get(Boolean.FALSE).get(), equalTo(1));
      assertThat(responses.get("Relocation package?").
            get(Boolean.TRUE).get(), equalTo(2));
      assertThat(responses.get("Relocation package?").
            get(Boolean.FALSE).get(), equalTo(0));
   }
}
cs

 

위와 같이 테스트 코드를 작성할 수 있게 된다. 데이터베이스 액세스하는 로직을 별도의 메서드로 분리하였고, 해당 메서드의 결과를 매개변수로 넘기는 구조로 변경하였기에 테스트 코드를 작성할때는 굳이 데이터베이스를 액세스하지 않고 로컬 메모리 영역 내에서 해결이 가능하게 되었다. 결론적으로 데이터베이스 액세스 시간을 줄이므로써 훨씬 빠른 테스트 코드를 작성할 수 있게 되었다.

 

2)Isolated : 고립된

좋은 단위 테스트는 검증하려는 작은 양의 코드에 집중한다. 이것이 우리가 단위라고 말하는 정의와 부합한다. 직접적 혹은 간접적으로 테스트 코드와 상호 작용하는 코드가 많아질수록 문제가 발생할 소지가 늘어난다.

 

테스트 대상 코드는 데이터베이스를 읽는 다른 코드와 상호 작용할 수도 있다. 데이터 의존성은 많은 문제를 만들기도 한다. 궁극적으로 데이터베이스에 의존해야 하는 테스트는 데이터베이스가 올바른 데이터를 가지고 있는지 확인해야 한다. 그리고 데이터 소스를 공유한다면 테스트를 깨드릴 수 있는 외부 변화도 고민해보아야 한다. 외부 저장소와 상호작용하게 된다면, 꼭 테스트가 가용성 혹은 접근성 이슈로 실패할 가능성이 있다는 것을 염두하자.

 

또 좋은 단위 테스트는 다른 단위 테스트에 의존하지 않는다. 흔히 여러 테스트가 값비싼 초기화 데이터를 재사용하는 방식으로 테스트 순서를 조작하여 전체 테스트의 실행 속도를 높이려 할 수도 있는데, 이러한 의존성은 악순환을 유발할 수 있다. 따라서 테스트 코드는 어떤 순서나 시간에 관계없이 실행할 수 있어야한다.

 

각 테스트가 작은 양의 동작에만 집중하면 테스트 코드를 집중적이고 독립적으로 유지하기 쉬워진다. 하나의 테스트 메서드에 두 번째 assert 구문을 추가할 때, "이 assert 구문이 단일 동작을 검증하도록 돕는가, 아니면 내가 새로운 테스트 이름으로 기술할 수 있는 어떤 동작을 대표하는가"를 스스로 질문해보고 살펴보자.

 

객체지향설계에 대한 이야기이지만 테스트 코드에도 동일하게 적용될 수 있는 단일 책임 원칙(SRP)을 따르자 ! 즉, 테스트 코드도 하나의 단일 행동을 테스트하는 코드여야 한다. 혹여나 테스트 메서드가 하나 이상의 이유로 깨진다면 테스트를 분할하는 것도 고려하자.

 

3)Repeatable : 반복가능한

반복 가능한 테스트는 실행할 때마다 결과가 같아야 한다. 즉, 어떤 상황에서는 성공, 어떤 상황에서는 실패하는 테스트코드를 만들지 말아야한다. 반복가능한 테스트를 만들기 위해서는 직접 통제할 수 없는 외부 환경에 있는 항목들과 격리시켜야 한다. 하지만 시스템은 불가피하게 통제할 수 없는 요소와 상호 작용해야 할 것이다. 예를 들면 현재 시간을 다루어야 하는 테스트 코드는 반복 가능한 테스트를 힘들게 하는 요인이 될 수 있다. 이때는 테스트 대상 코드의 나머지를 격리하고 시간 변화에 독립성을 유지하는 방법으로 목 객체를 사용할 수도 있다.

 

4)Self-validating : 스스로 검증가능한

테스트는 기대하는 결과가 무엇인지 단언(assert)하지 않으면 올바른 테스트라고 볼 수 없다. 단위 테스트는 오히려 개발 시간을 단축시켜준다. main 메서드 안에 표준출력 등을 이용하여 수동으로 검증하는 것은 시간 소모적인 절차고 테스트 위험의 리스크가 증가할 수 있다. JUnit 같은 스스로 검증 가능한 테스트 코드를 작성하거나 더 큰 규모에서는 젠킨스 등 지속적 통합(CI) 도구를 활용할 수 있다. 

 

5)Timely : 적시의

단위테스트를 뒤로 미루지 말고 적시(즉시)에 작성하자. 단위 테스트를 점점 뒤로 미룰 수록 더욱더 테스트를 작성하기 어려워진다.