'2019/09/06'에 해당되는 글 1건

  1. 2019.09.06 :: TDD - 단위 테스트, 무엇을 테스트 해야할까? Right-BICEP 1
Web/TDD 2019. 9. 6. 14:28

 

우리가 직접 작성한 코드라고 하더라도 단순히 메서드, 클래스 코드를 보면서 숨어 있는 버그를 찾아내는 것은 거의 불가능하다. 물론 코드를 살펴보는 것도 하나의 버그를 찾아내는 행위이긴 하다. 하지만 효율적인 단위 테스트를 통해 숨어있는 버그를 찾아내는 것이 훨씬 더 효율적인 방안일 듯하다. 그렇다면 테스트를 수행하는 것 만큼 중요한 것은 무엇일까? 아니, 올바르고 더 효율적인 테스트를 수행하기 위해 받침이 되는 행위가 무엇일까? 바로 테스트 케이스! 무엇을 테스트 해야할까를 고민하는 것은 아주 중요하고 여기서 그치는 것이 아니라 정말 중요한 테스트 케이스를 찾아 테스트에 적용해야한다.

 

Right-BICEP는 우리에게 무엇을 테스트할지에 대해 쉽게 선별하게 한다.

 

  • Right : 결과가 올바른가?
  • B : 경계 조건은 맞는가?
  • I : 역 관계를 검사할 수 있는가?
  • C : 다른 수단을 활용하여 교차 검사할 수 있는가?
  • E : 오류 조건을 강제로 일어나게 할 수 있는가?
  • P : 성능 조건은 기준에 부합하는가?

1)Right :  결과가 올바른가?

테스트 코드는 무엇보다도 먼저 기대한 결과(단언,assert)를 선출하는지 검증할 수 있어야한다. 이 말은 무엇이냐? 어떠한 코드가 있고 "해당 코드가 정상적으로 동작한다면 어떠한 입력이 들어오더라도 정상적으로 출력되는 결과를 알수 있나?"를 답할 수 있어야 한다는 말이다. 만약 어떠한 코드가 어떤 정상적인 결과를 낼것이다라는 것도 모르고 테스트 코드를 작성하는 것은 옳지 않은 방법이다. 이럴때에는 추가 개발을 잠시 보류하는 것이 좋다. 하지만 무작정 개발을 보류하는 것이 아니라 최선의 판단을 통한 보류를 해야한다는 것이다.

 

결론적으로 우리가 해당 코드의 올바른 출력을 이해하고 예상가능 해야할 때, 테스트 코드를 작성해야 한다는 것이다. 이것은 단순하고 당연하면서도 아주 중요한 질문이 된다.

 

2)B : 경계 조건은 맞는가?

보통 개발자는 테스트 코드에 assert 구문을 작성할 때, 예상가능한 입력값 등으로 작성을 하게 된다. 하지만 이러한 입력값은 테스트 시나리오의 경계 조건에 해당하는 테스트 케이스가 아닐 수 있다. 우리가 대부분 마주치는 수많은 결함은 경계 조건에 해당하는 테스트 케이스에서 많이 나오기 때문에 모서리 사례(corner case, 경계조건)를 처리해야 한다.

 

많이 생각할 수 있는 경계조건은 아래와 같다.

  • 모호하고 일관성 없는 입력 값. 예를 들어 특수 문자("!*:\&/$>#@ 등)가 포함된 파일 이름
  • 잘못된 양식의 데이터. 예를 들어 최상위 도메인이 빠진 이메일 주소
  • 수치적 오버플로를 일으키는 계산
  • 비거나 빠진 값. 예를 들어 0, 0.0, "" 혹은 Null
  • 이성적인 기댓값을 훨씬 벗어나는 값. 예를 들어 200세의 나이
  • 교실의 당번표처럼 중복을 허용해서는 안 되는 목록에 중복 값이 있는 경우
  • 정렬이 안 된 정렬 리스트 혹은 그 반대. 예를 들어 사용중인 정렬 알고리즘이 퀵소트인데, 역순으로 정렬된 데이터가 들어오는 경우
  • 시간 순이 맞지 않은 경우. 예를 들어 HTTP 서버가 OPTIONS 메서드의 결과를 POST 메서드보다 먼저 반환해야 하지만 그 후에 반환하는 경우

이전 포스팅에서 다루어봤던 예제 코드로 경계 조건을 살펴보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ScoreCollection {
   private List<Scoreable> scores = new ArrayList<>();
   
   public void add(Scoreable scoreable) {
      scores.add(scoreable);
   }
   
   public int arithmeticMean() {
      int total = scores.stream().mapToInt(Scoreable::getScore).sum();
      return total / scores.size();
   }
}
 
@FunctionalInterface
public interface Scoreable {
   int getScore();
}
 
 
cs

 

위와 같은 코드가 있고 해당 코드들의 테스트 코드를 아래와 같이 경계 조건 몇몇을 포함하여 작성했다.

 

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
public class ScoreCollectionTest {
   private ScoreCollection collection;
 
   @Before
   public void create() {
      collection = new ScoreCollection();
   }
 
   @Test
   public void answersArithmeticMeanOfTwoNumbers() {
      collection.add(() -> 5);
      collection.add(() -> 7);
      
      int actualResult = collection.arithmeticMean();
      
      assertThat(actualResult, equalTo(6));
   }
   
   @Test(expected=IllegalArgumentException.class)
   public void throwsExceptionWhenAddingNull() {
      collection.add(null);
   }
   
   @Test
   public void answersZeroWhenNoElementsAdded() {
      assertThat(collection.arithmeticMean(), equalTo(0));
   }
 
   @Test
   public void dealsWithIntegerOverflow() {
      collection.add(() -> Integer.MAX_VALUE); 
      collection.add(() -> 1); 
      
      assertThat(collection.arithmeticMean(), equalTo(1073741824));
   }
}
cs

 

첫번째 테스트 코드는 어느 개발자가 넣을 수 있는 예상가능한 입력 값이다. 하지만 그 다음부터 입력 값이 null일때, 혹은 계산을 수행하기 위한 Scorable 객체가 하나도 존재하지 않을때, 자료형의 최대값을 넘어섰을 경우 등의 경계 조건을 테스트 코드로 넣었다. 물론 해당 테스트 코드를 작성하고 실제 프로덕 코드에 아무런 조치를 하지 않는 다면, 두번째는 NullPointerException, 세번째는 ArithmeticException 등이 발생할 것이다. 이러한 것들을 방지하기 위해 프로덕 코드에 보호절을 넣어 입력 범위를 분명하게 해야한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ScoreCollection {
   private List<Scoreable> scores = new ArrayList<>();
   
   public void add(Scoreable scoreable) {
      if (scoreable == nullthrow new IllegalArgumentException();
      scores.add(scoreable);
   }
   
   public int arithmeticMean() {
      if (scores.size() == 0return 0;
      // ...
      
      long total = scores.stream().mapToLong(Scoreable::getScore).sum();
      return (int)(total / scores.size());
   }
}
cs

 

경계 조건을 설정한 테스트 코드를 작성하고 수행하므로써 경계 조건에 해당 하는 입력 값에 대한 보호절을 프로덕 코드에 적용할 수 있었다. 이렇게 항상 성공하는 테스트 케이스만 작성하는 것이 아니라, 경계조건에 해당하는 테스트 케이스도 고려해야 건강한 코드가 될 수 있다.(네번째 테스트 케이스는 다운 캐스팅으로 코드를 변경하므로써 해결하였다.)

 

만약 클래스가 외부에서 호출하는 API이고 클라이언트를 완전히 믿을 수 없다면 이러한 나쁜 데이터에 대한 보호가 더욱 필요하게 된다.

 

이러한 잠재적인 경계 조건을 뽑아내는데 CORRECT 약어는 많은 도움을 우리에게 준다.

  • Conformance(준수) :  값이 기대한 양식을 준수하고 있는가?
  • Ordering(순서) : 값의 집합이 적절하게 정렬되거나 정렬되지 않았나?
  • Range(범위) : 이성적인 최소값과 최대값 안에 있는가?
  • Reference(참조) : 코드 자체에서 통제할 수 없는 어떤 외부 참조를 포함하고 있는가?
  • Existence(존재) : 값이 존재하는가(널이 아니거나, 0이 아니거나, 집합에 존재하는가 등)?
  • Cardinality(기수) : 정확히 충분한 값들이 있는가?
  • Time(절대적 혹은 상대적 시간) : 모든 것이 순서대로 일어나는가? 정확한 시간에? 정시에?

해당 내용에 대해서는 다음 포스팅에서 다루어볼 것이다.

 

3)I : 역 관계를 검사할 수 있는가?

테스트할때 때때로 논리적인 역 관계를 적용하여 행동을 검사할 수 있다. 종종 수학 계산에서 사용하기도 한다. 예를 들면 곱셈으로 나눗셈을 검증하고 뺄셈으로 덧겜을 검증하는 것이다.

그래서 우리는 테스트케이스를 검사할 때, 원래의 테스트케이스의 역 관계를 이용하여(프레디케이트의 보어) 테스트를 해 긍정 사례 답변들과 역 답변을 합하면 전체가 되는 테스트를 수행할 수 있다.

 

4)C : 다른 수단을 활용하여 교차 검사할 수 있는가?

예를 들면, 제곱근을 구하는 메서드가 있다면 우리는 자바의 Math.sqrt()를 이용해 결과가 동일한지 테스트할 수도 있지만 다른 수단을 이용해 조금은 정확도가 떨어지는 제곱근 자바 라이브러리를 이용해서도 테스트할 수 있다. 물론 동일한 결과를 내는지 확인해야한다. 이렇게 어떠한 상황에서든지 동일한 테스트 결과를 낼수 있어야한다.

 

5)E : 오류 조건을 강제로 일어나게 할 수 있는가?

행복 경로가 있다는 것은 반대로 불행한 경로도 있다는 것을 의미한다. 오류가 절대로 발생할 수 없다고 생각할 수도 있지만, 디스크가 꽉 차거나, 네트워크 오류, 이메일이 블랙홀에 빠지고 프로그램이 중단될 수 있다. 우리는 테스트 코드를 작성할 때 이러한 모든 실전 문제를 우아하고 이성적인 방식으로 다루어야 한다. 그렇게 하기 위해서는 테스트도 오류들을 강제로 발생시켜야한다. 

 

유효하지 않은 인자들을 다루는 등의 일은 쉽지만 특정 네트워크 오류를 시뮬레이션하려면 특별한 기법이 필요하다. 다음은 환경적인 제약 사항들을 담은 오류상황이다.

 

  • 메모리가 가득 찰 때
  • 디스크 공간이 가득 찰 때
  • 벽시계 시간에 관한 문제들(서버와 클라이언트 간 시간이 달라서 발생하는 문제)
  • 네트워크 가용성 및 오류들
  • 시스템 로드

이러한 상황을 다루는 테스트 코드는 추후 포스팅에서 다루어볼 것이다.

 

6)P : 성능 조건은 기준에 부합하는가?

구글의 롭 파이크는 "병목 지점은 놀라운 곳에서 일어납니다. 따라서 실제로 병목이 어디인지 증명되기 전까지는 미리 잠직하여 코드를 난도질하지 마세요"라고 말한다. 사실 필자도 예전에는 그랬었지만, 프로그래머는 성능 문제가 어디에 있으며 최적의 해법이 무엇인지 추측한다.

사실 추측만으로 성능 문제에 바로 대응하기보다는 단위 테스트를 설계하여 진짜 문제가 어디에 있으며 예상한 변경 사항으로 어떤 차이가 생겼는지 파악해야 한다.

 

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
public class ScoreCollection {
 
   ...
 
   private long run(int times, Runnable func) {      
      long start = System.nanoTime();
      for (int i = 0; i < times; i++)
         func.run();
      long stop = System.nanoTime();
      return (stop - start) / 1000000;
   }
 
   @Test
   public void findAnswers() {
      int dataSize = 5000;
      for (int i = 0; i < dataSize; i++)
         profile.add(new Answer(
               new BooleanQuestion(i, String.valueOf(i)), Bool.FALSE));
      profile.add(
         new Answer(
           new PercentileQuestion(
                 dataSize, String.valueOf(dataSize), new String[] {}), 0));
 
      int numberOfTimes = 1000;
      long elapsedMs = run(numberOfTimes,
         () -> profile.find(
               a -> a.getQuestion().getClass() == PercentileQuestion.class));
 
      assertTrue(elapsedMs < 1000);
   }
   
   ...
}
cs

 

위오 같이 코드 수행 시간을 체크하는 성능 테스트가 있다고 생각해보자. 과연 유용한 테스트 코드 일까?

 

우선 수행시간과 같이 성능에 대한 테스트를 할 경우 아래와 같은 사항들을 주의해야한다.

  • 전형적으로 코드 덩어리를 충분한 횟수만큼 실행해야한다. 이렇게 타이밍과 CPU 클록 주기에 관한 이슈를 제거한다.
  • 반복하는 코드 부분을 자바(JVM)가 최적화하지 못하는지 확인해야 한다.
  • 최적화되지 않은 테스트는 한 번에 수 밀리초가 걸리는 일반적인 테스트 코드들보다 매우 느리다. 느린 테스트들은 빠른 것과 분리한다. 
  • 동일한 머신이라도 실행 시간은 시스템 로드처럼 잡다한 요소에  따라 달라질 수 있다.

우선 앞선 코드에서는 검색 동작이 1초 안에 1000번 실행 가능한지 단언한다. 하지만 1초라는 것은 매우 주관적인 수치이다. 거대한 서버에서 테스트를 실행하면 충분히 빠르겠지만 사양이 형편없는 데스크탑에서 실행하면 그다지 빠르지 않을 것이다. 유일한 해법은 테스트 환경을 프로덕 환경과 가장 유사한 환경(머신)에서 실행하는 것이다.

 

그리고 초당 1000번의 조건은 객관적이지 못하다. 성능 요구 사항은 보통 종단 간(end-to-end) 기능을 기반으로 하지만 앞선 테스트는 단위 수준으로 검증하고 있다.

 

테스트하는 메서드가 사용자에게 보여지는 진입점이 아니라면 마치 사과와 오렌지를 비교하는 것과 같은 수준이다.

 

단위 성능 측정을 활용하는 방안은 변경 사항을 만들 때, 기준점으로 활용하는 것이다. 어떠한 메서드가 최적화되지 못하는 코드라고 가정하면 성능이 향상되는지 확인하기 위해 로직을 교체할 것이다. 최적화를 하기 전에 먼저 이전 코드로 기준점(실행 경과 시간)을 잡고 이후 변경된 로직의 실행 경과 시간을 계산해 성능 결과를 비교한다.(한번의 실행이 아닌 다수의 실행으로 평균 실행 경과 시간을 구해야한다. 실제로 처음 실행되는 코드는 클래스가 로드되고 캐시되지 않은 데이터가 많기에 느리다.)

 

*모든 성능 최적화 시도는 실제 데이터로 해야 하며 추측을 기반으로 해서는 안된다.

 

사실 성능이 핵심 고려 사항이라면 단위 테스트보다는 더 고수준으로 JMeter 도구 등을 이용한 테스트를 하는 것이 나을 수도 있다.

posted by 여성게
: