Web/Spring 2020. 3. 20. 21:44

 

이전 시간에 DB관련 테스트 작성하는 법을 다루어 봤는데, 이번 시간에는 Webflux handler 테스트 코드를 한번 작성해보려고 한다. 이전까지는 service 단까지만 테스트를 모듈별로 작성하였지만, 핸들러로 인입하여 한번에 모든 로직을 돌려보는 테스트는 직접 넣어보지 않았던 것 같다.(사실 핸들러, 컨트롤러 테스트가 이것저것 설정해야할 것들이 많아서..)

 

간단히 바로 예제를 다루어본다.

 

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
@Configuration
public class RouteConfig {
 
    @Bean
    public RouterFunction<ServerResponse> routeFunction(TestHandler testHandler) {
        return route()
                .nest(path("/"), builder -> builder
                        .GET("/test", accept(APPLICATION_JSON), testHandler::testHandler)
                ).build();
    }
}
 
@Component
public class TestHandler {
 
    @Autowired
    private TestService testService;
 
    public Mono<ServerResponse> testHandler(ServerRequest serverRequest) {
        return ServerResponse.ok().body(BodyInserters.fromProducer(testService.testService(), String.class));
    }
}
 
@Service
public class TestService {
    public Mono<String> testService() {
        return Mono.just("testResponse");
    }
}
cs

 

간단하게 handler와 service 클래스를 구현하였다. 단순히 String을 반환하고 있다. 해당 핸들러를 테스트하는 코드는 아래와 같다.

 

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
@SpringBootTest(classes = {
        TestHandler.class,
        RouteConfig.class,
        TestService.class
})
@MockBeans({
})
class HandlerTest {
 
    private WebTestClient client;
    @Autowired
    private RouteConfig routeConfig;
    @Autowired
    private TestHandler testHandler;
 
    private static final String ENDPOINT = "/test";
 
    @BeforeEach
    public void beforeTest() {
        client = WebTestClient
                .bindToRouterFunction(routeConfig.routeFunction(testHandler))
                .build();
    }
 
    @Test
    public void handlerTest() {
        client.get()
                .uri(ENDPOINT)
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .value(value -> {
                    assertEquals(value, "testResponse");
                });
    }
 
}
cs

 

spring MVC와는 조금 다를 수 있다. 해당 테스트 코드를 간단하게 설명하면 우리가 작성한 routeConfig를 WebTestClient에 바인딩해준다. 그 이후에 해당 클라이언트를 이용하여 routeConfig 내에 존재하는 라우팅 엔드포인트에 요청을 날리면 핸들러 로직을 수행후 응답을 내려준다. 그리고 해당 응답을 이용하여 원하는 응답이 나왔는지 assert 해볼 수 있다. 위는 정상적인 경우만 다루어보았고, 한번 없는 요청을 보내보자.

 

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
@SpringBootTest(classes = {
        TestHandler.class,
        RouteConfig.class,
        TestService.class
})
@MockBeans({
})
class HandlerTest {
 
    private WebTestClient client;
    @Autowired
    private RouteConfig routeConfig;
    @Autowired
    private TestHandler testHandler;
 
    private static final String ENDPOINT = "/test";
    private static final String INVALID_ENDPOINT = "/invalid";
 
    @BeforeEach
    public void beforeTest() {
        client = WebTestClient
                .bindToRouterFunction(routeConfig.routeFunction(testHandler))
                .build();
    }
 
    @Test
    public void handlerTest() {
        client.get()
                .uri(ENDPOINT)
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .value(value -> {
                    assertEquals(value, "testResponse");
                });
    }
 
    @Test
    public void notFoundHandlerMappingTest() {
        client.get()
                .uri(INVALID_ENDPOINT)
                .exchange()
                .expectStatus().is4xxClientError();
    }
 
}
cs

 

테스트를 하나더 추가하였다. 이 요청 url은 존재하지 않는 handler mapping이므로 정상적이라면 404 not found를 응답코드로 내려줄 것이라고 기대하고 테스트코드를 작성하였다.

 

실행해보면 테스트가 정상적으로 통과한다.

 

여기까지 간단하게 웹플럭스 핸들러 테스트를 작성해보았다. 아주 간단하게만 작성하였지만 테스트에 고려할만한 TC(Test Case)는 아주 많은 것같다. 400 예외를 고려한 테스트, 혹은 특정 예외를 고려한 테스트 혹은 모든 로직이 잘 동작하는 테스트 마지막으로 파라미터마다 동작이 다른것을 테스트 해보는 경우등 아주 많은 경우가 있을 것이다. 모든 테스트케이스를 잘 고려해 테스트를 짜보자.

 

마지막으로 만약 애플케이션에 ErrorHandler가 적용되어있다면 위와 같이 짠 테스트 코드는 ErrorHandler가 내뱉는 응답을 받지 못한다. 왜냐 테스트 컨텍스트 빈으로 등록하지 않았기 때문이다. 만약 파라미터가 잘못들어와 로직상에서 예외를 던졌고, 만약 그 예외를 ErrorHandler가 받아서 400이라는 HttpStatusCode로 리턴하고 있는데 그것을 기대하고 파라미터가 잘못들어온 경우를 is4xxClientError()로 잡으면 테스트는 통과하지 못한다. 그 이유는 WebTestClient는 500에러를 내뿜고 있기 때문이다. 그 이유는 위에 말한 것과 같이 ErrorHandler를 테스트 컨텍스트 빈으로 등록하지 않아서 4xx로 변환되지 못하고 애플리케이션 로직상에서 나는 예외(500)로 인식하기 때문이다. 

posted by 여성게
:
Web/Spring 2019. 9. 10. 15:22

 

이번 포스팅은 @RequestParam과 @ModelAttribute의 차이점에 대해 다루어볼 것이다. 이번에 다루어볼 내용은 특정 유저관련 컨트롤러 코드를 작성하여 살펴볼 것이다.

 

작업환경은 MacOS+intelliJ로 구성하였다.

 

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
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @GetMapping("/insert")
    public Object insertUser(@ModelAttribute("findUser") User user, BindingResult bindingResult){
 
        if(bindingResult.hasErrors()){
            log.info("binding error");
            return "invalidParam";
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
 
    @GetMapping("/insert2")
    public Object insertUser(@RequestParam int id, @RequestParam String firstName, @RequestParam String lastName){
        return "RequestParamBinding";
    }
 
}
 
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class User{
 
    private int id;
    private String firstName;
    private String lastName;
    private int age;
    private Level level;
 
}
cs

 

위와 같은 코드가 있다. 세부적인 로직은 제외하고 파라미터를 바인딩하는 부분을 집중해보자. 우선 @RequestParam과 @ModelAttribute의 파라미터 바인딩에는 큰 차이가 있다. 바로 객체로 받는냐, 혹은 쿼리스트링을 하나하나 바인딩 받느냐의 차이이다. 이것은 코드의 가독성에 아주 큰 차이가 있다. 두번째 차이점은 @ModelAttribute는 사용자가 직접 View에 뿌려질 Model객체를 직접 set하지 않아도 된다는 점이다. @ModelAttribute는 스프링이 직접 ModelMap에 객체를 넣어주기 때문이다. 이러한 바인딩 차이점 이외에 한가지 아주 큰 차이점이 있다.

 

매핑을 보면 @ModelAttribute에는 BindingResult라는 인자가 들어가있는 것을 볼 수 있다. 하지만 @RequestParam은 없다. 과연 무슨 말일까?

 

@ModelAttribute는 사실 파라미터를 검증할 수 있는 기능을 제공한다. 이 말은 파라미터 바인딩에 예외가 발생하여도 혹은 파라미터 바인딩은 문제없이 됬으나 로직처리에 적합하지 않은 파라미터가 들어오는 등 이러한 파라미터를 검증할 수 있는 기능이 있다. 만약 파라미터 바인딩에 예외가 발생하였을 때, @ModelAttribute와 @RequestParam의 차이점을 보자. 전자는 바인딩 예외를 BindingResult에 담아준다. 즉, 4xx 예외가 발생하지 않는 것이다. 하지만 @RequestParam 같은 경우 바인딩 예외가 발생하는 순간 TypeMisMatch 예외를 발생시켜 사용자에게 4xx status code를 전달한다. 

 

그러면 개발자는 파라미터를 검증하는 로직을 컨트롤러 코드와 따로 분리하여 바인딩된 파라미터의 값의 유효성을 검사할 수 있게 해준다. 만약 @RequestParam이라면 직접 비즈니스 로직내에서 파라미터를 검증하는 코드가 들어가야 할 것이다.

 

바인딩 예외 처리의 차이점을 간단한 테스트 코드로 테스트 해보았다.

 

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
@SpringBootTest
@RunWith(SpringRunner.class)
public class UserControllerTest {
 
    private static final String FINDBYID_MODELATTRIBUTE_REQUEST_URI = "/user/find";
    private static final String FINDBYID_REQUESTPARAM_REQUEST_URI = "/user/find2";
    private User validParamForFindByUserId ;
 
    private MockMvc mockMvc;
 
    @Before
    public void init(){
        this.mockMvc = MockMvcBuilders.standaloneSetup(new UserController()).build();
    }
 
    @Before
    public void initParam(){
        validParamForFindByUserId = new User(1,"yeoseong","yoon",28,Level.valueOf(1));
    }
 
    @Test
    public void paramVaildationWhenInvalidInputParamWithModelAttribute() throws Exception {
        MvcResult createResult = mockMvc.perform(get(FINDBYID_MODELATTRIBUTE_REQUEST_URI+"?id=abc"))
                                        .andDo(print())
                                        .andExpect(content().string("invalidParam"))
                                        .andReturn()
                                        ;
    }
 
    @Test
    public void paramValidationWhenInvalidInputParamWithRequestParam() throws Exception {
        MvcResult createResult = mockMvc.perform(get(FINDBYID_REQUESTPARAM_REQUEST_URI+"?id=abc"))
                                        .andDo(print())
                                        .andExpect(status().is4xxClientError())
                                        .andReturn()
                                        ;
    }
 
}
cs

 

위 테스트를 돌려보면 우리가 기대하는 바인딩 예외처리 테스트가 무사히 통과할 것이다.

posted by 여성게
:
Web/TDD 2019. 9. 10. 12:59

 

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

 

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

 

TDD의 주된 이익

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

 

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

 

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

 

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

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

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

 

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

 

1
2
3
4
5
6
public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      new Profile();
   }
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
   }
}
cs

 

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

 

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

 

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

 

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
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);
   }
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
public class Profile {
   private Answer answer;
 
   public boolean matches(Criterion criterion) {
      return answer != null;
   }
 
   public void add(Answer answer) {
      this.answer = answer;
   }
}
cs

 

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

 

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

 

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
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);
   }
}
cs

 

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

 

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

 

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

 

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 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);
   }
}
cs

 

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

 

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
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;
   }
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
   }
}
cs

 

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

 

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

 

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
59
60
61
62
63
64
65
66
67
68
69
70
71
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);
   }
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
   }
}
cs

 

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

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
   }
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Answer {
   private int i;
   private Question question;
 
   ...
 
   public boolean match(Answer otherAnswer) {
      if (otherAnswer == nullreturn false;
      // ...
      return question.match(i, otherAnswer.i);
   }
 
   ...
}
cs

 

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

 

1
2
3
4
5
6
7
public class AnswerTest {
   @Test
   public void matchAgainstNullAnswerReturnsFalse() {
      assertFalse(new Answer(new BooleanQuestion(0""), Bool.TRUE)
        .match(null));
   }
}
cs

 

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

 

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

 

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
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);
   }
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
   }
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
   }
}
cs

 

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

 

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

 

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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);
   }
}
cs

 

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

 

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

 

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

 

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

 

posted by 여성게
:
Web/TDD 2019. 9. 10. 11:26

 

단위 테스트, 혹은 여느 테스트 코드를 작성하는 일은 상당한 투자와 비용이 드는 작업이다. 하지만 테스트는 프로덕 코드의 결함을 최소화하고 리팩토링으로 프로덕 시스템을 깔끔하게 유지시켜준다. 그렇지만 역시 지속적인 비용을 의미하는 것은 부정할 수 없다. 시스템이 변경됨에 따라 테스트 코드도 다시 들여다보아야한다. 때때로 변경 사항들을 생겨나고 그 결과로 수많은 테스트 케이스가 깨져 테스트 코드를 수정해야 한다.

 

그렇다면 만약 테스크 코드가 굉장히 복잡하고 지저분하다면 어떻게 될까? 새로운 변경사항이 생겨날 때마다 테스트 코드를 수정하는 일은 더욱 더 힘든 일이 될 것이다. 그래서 이번 포스팅에서는 테스트 코드를 리팩토링하여 유지보수가 쉬운 테스트 코드를 만드는 것을 간단하게 소개할 것이다. 이번 포스팅의 목표는 리팩토링의 대상이 프로덕 코드만이 아니라 테스트 코드도 되어야한다라는 것을 알기 위한 글이다.

 

검색 기능에 대한 테스트 코드가 아래와 같이 있다. 하지만 해당 테스트 코드를 보면 이 테스트 코드가 어떠한 것을 검증하고 증명하려하는지 한눈에 파악하기 힘든 코드이다. 더군다나 우리는 Search 하는 클래스가 정확히 어떤 일을 하는 지도 모른다.

 

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
public class SearchTest {
   @Test
   public void testSearch() {
      try {
        String pageContent = "There are certain queer times and occasions "
              + "in this strange mixed affair we call life when a man "
              + "takes this whole universe for a vast practical joke, "
              + "though the wit thereof he but dimly discerns, and more "
              + "than suspects that the joke is at nobody's expense but "
              + "his own.";
         byte[] bytes = pageContent.getBytes();
         ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
         // search
         Search search = new Search(stream, "practical joke""1");
         Search.LOGGER.setLevel(Level.OFF);
         search.setSurroundingCharacterCount(10);
         search.execute();
         assertFalse(search.errored());
         List<Match> matches = search.getMatches();
         assertThat(matches, is(notNullValue()));
         assertTrue(matches.size() >= 1);
         Match match = matches.get(0);
         assertThat(match.searchString, equalTo("practical joke"));
         assertThat(match.surroundingContext, 
               equalTo("or a vast practical joke, though t"));
         stream.close();
 
         // negative
         URLConnection connection = 
               new URL("http://bit.ly/15sYPA7").openConnection();
         InputStream inputStream = connection.getInputStream();
         search = new Search(inputStream, "smelt""http://bit.ly/15sYPA7");
         search.execute();
         assertThat(search.getMatches().size(), equalTo(0));
         stream.close();
      } catch (Exception e) {
         e.printStackTrace();
         fail("exception thrown in test" + e.getMessage());
      }
   }
}
cs

 

이 테스트 코드의 문제점 몇 가지를 찾아보자.

  • 테스트 이름인 testSearch는 어떤 유용한 정보도 제공하지 못한다.
  • 몇 줄의 주석은 우리에게 어떠한 도움도 주지 못한다.
  • 다수의 단언(assert)가 존재해 무엇을 증명하고 검증하려고 하는지 이해하지 못한다.

더 많은 냄새가 나지만 일단 위와 같은 문제점들이 딱 눈에 보인다. 우리는 이 코드를 깔끔하게 리팩토링 해볼 것이다.

 

1)불필요한 테스트 코드

위 테스트 코드에 크게 감싸고 있는 try/catch 구문은 사실 크게 이점이 없는 코드 블록이다. 현재 테스트 코드에서 해당 블록이 하는 역할은 예외가 발생하면 스택 트레이서를 출력하고 테스트를 실패시키며 예외 메시지를 뿌려주는 역할을 한다. 하지만 사실 try/catch 구문이 없더라도 JUnit은 발생한 예외를 잡아 스택 트레이서를 뿌려주고 테스트에 오류가 발생함을 알려주기 때문에 위의 try/catch는 불필요하다.(무조건 테스트에서 try/catch구문이 필요없다는 것이 아니다.)

 

두번째는 20Line에 있는 notNullValue() 부분이다. 바로 21Line에는 matchs의 사이즈가 1보다 크거나 같음을 이미 테스트하고 있다. 또한 널을 체크하는 구문이 없더라도 만약 matches가 널이라면 21Line에서 테스트 실패가 날것이므로 굳이 널 체크 단언을 넣을 필요가 없다. 물론 프로덕 코드에서는 널 체크하는 구문이 아주 큰 역할을 할 수는 있다. 하지만 이 테스크 코드에서는 크게 이점이 없는 코드구문이다.

 

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 SearchTest {
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      // ...
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke""1");
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      List<Match> matches = search.getMatches();
      assertTrue(matches.size() >= 1);
      Match match = matches.get(0);
      assertThat(match.searchString, equalTo("practical joke"));
      assertThat(match.surroundingContext, equalTo(
            "or a vast practical joke, though t"));
      stream.close();
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt""http://bit.ly/15sYPA7");
      search.execute();
      assertThat(search.getMatches().size(), equalTo(0));
      stream.close();
   }
}
cs

 

위의 문제점을 고쳐 위와 같은 테스트 코드로 리팩토링하였다. 하지만 여전히 지저분해보이는 코드이다.

 

2)추상화 누락

위 코드에서는 다수의 단언(assert)가 존재한다. 이 중에서 20,22,23Line의 단언은 사실 하나의 개념을 구체화하고 있는 단언이다. 하나의 개념을 구체화하고 있다는 뜻은 하나의 assert 문으로도 충분히 테스트할 수 있는 케이스라는 뜻이다. 우리는 여기에서 사용자 정의 매처를 생성하여 위의 3개의 단언문을 하나의 단언으로 리팩토링할 것이다.

 

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
public class ContainsMatches extends TypeSafeMatcher<List<Match>> {
   private Match[] expected;
 
   public ContainsMatches(Match[] expected) {
      this.expected = expected;
   }
 
   @Override
   public void describeTo(Description description) {
      description.appendText("<" + expected.toString() + ">");
   }
 
   private boolean equals(Match expected, Match actual) {
      return expected.searchString.equals(actual.searchString)
         && expected.surroundingContext.equals(actual.surroundingContext);
   }
 
   @Override
   protected boolean matchesSafely(List<Match> actual) {
      if (actual.size() != expected.length)
         return false;
      for (int i = 0; i < expected.length; i++)
         if (!equals(expected[i], actual.get(i)))
            return false;
      return true;
   }
 
   @Factory
   public static <T> Matcher<List<Match>> containsMatches(Match[] expected) {
      return new ContainsMatches(expected);
   }
}
cs

 

위 코드와 같이 사용자 정의 매처를 생성한다. 사용자 정의 매처는 햄크레스트의 TypeSafeMatcher<T>를 상속하여 구현할 수 있다. 첫번째 오버라이드하는 메서드 describeTo는 테스트 실패시 우리가 기대한 값을 출력하기 위한 메서드이다. 두번째 matchesSafely는 실제 expected값과 actual 값 비교를 위한 메서드이다. 마지막 containsMatches는 JUnit 코드에서 사용되는 팩토리 메소드이다.

 

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
public class SearchTest {
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            // ...
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke""1");
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[] { 
         new Match("1""practical joke"
                   "or a vast practical joke, though t") }));
      stream.close();
      // ...
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt""http://bit.ly/15sYPA7");
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
}
cs

 

우리는 사용자 정의 매처를 만들어서 위와 같이 3개의 assert를 하나의 assert 구문으로 바꾸는 마법을 부릴 수 있게 되었다. 한편으로는 "테스트 몇 줄을 줄이기 위해 사용자 정의 매처를 만드는 수고를 해야되?"라고 생각할 수는 있지만 이 사용자 정의 매처는 여러 테스트 코드에서 재활용될 수 있기에 충분한 가치가 있다.

 

그리고 마지막의 단언이였던 search.getMatches의 크기가 equalsTo(0)인가라는 단언은 위와 같이 .isEmpty()로 바꾸어서 조금 더 사실적인 정보를 줄 수 있다.

 

3)부절절한 정보

잘 추상화된 테스트는 코드를 이해하는 데 중요한 것을 부각시켜 주고 그렇지 않은 것은 보이지 않게 해준다. 테스트에 사용되는 데이터는 어떠한 테스트 시나리오를 설명할 수 있게 도움을 주어야 한다.

 

때때로 테스트에는 부적절하지만, 당장 컴파일 에러를 피하기 위해 데이터를 넣기도 한다. 예를 들어 메서드가 테스트에는 어떤 영향도 없는 부가적인 인수를 취하기도 한다.

 

테스트는 그 의미가 불분명한 "매직 리터럴"들을 포함하고 있다.

매직 리터럴 - 프로그래밍에서 상수로 선언하지 않은 숫자 리터럴을 "매직 넘버"라고 하며, 코드에는 되도록 사용하면 안된다.

 

1
2
3
4
5
6
7
8
9
...
 
Search search = new Search(stream, "practical joke", "1");
 
assertThat(search.getMatches(), containsMatches(new Match[] { 
         new Match("1", "practical joke", 
                   "or a vast practical joke, though t") }));
 
...
cs

 

위 코드를 보면 상수 "1"이 무슨 역할을 하는지 확신할 수 없다. 따라서 그 의미를 파악하기 위해서는 Search와 Match 클래스를 까봐야한다.(실제 코드 내부적으로는 "1"이 검색 제목을 의미하며 실제로 검색에 사용되지 않는 필드 값이다.)

 

"1"을 포함한 매직 리터럴은 불필요한 질문을 유발한다. 또한 이 값이 테스트에서 어떠한 영향을 미치는 지 소스를 파느라 시간을 낭비하게 될 것이다.

 

1
2
3
4
URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", "http://bit.ly/15sYPA7");
cs

 

또한 위의 URL 값은 어떻게 보면 위의 URL과 연관성이 있어 보이지만, 실제로는 무관한 값이다. 이렇게 "1"과 URL 값 같이 의미가 불분명하거나 혼란스러운 상황을 유발하는 리터럴 같은 경우는 의미파악이 쉬운 상수로 대체하면 의미를 분명히 전달할 수 있게 된다.

 

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 SearchTest {
   private static final String A_TITLE = "1";
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[] 
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
      stream.close();
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
}
cs

 

혹은 빈 문자열 등을 인자로 넘겨 테스트와는 무관한 값임을 표현하는 것도 하나의 방법이 될 수 있다.

 

4)부푼 생성

테스트 코드를 보면 Search 생성자에 InputStream 객체를 넘기고 있다. 또한 이 InputStream를 만들기 위해서 3개의 라인을 차지 하고 있다. 이러한 생성 관련된 코드를 하나의 메서드로 분리하면 여러 코드에서도 재활용가능하다.

 

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
public class SearchTest {
   private static final String A_TITLE = "1";
 
   @Test
   public void testSearch() throws IOException {
      InputStream stream =
            streamOn("There are certain queer times and occasions "
             + "in this strange mixed affair we call life when a man "
             + "takes this whole universe for a vast practical joke, "
             + "though the wit thereof he but dimly discerns, and more "
             + "than suspects that the joke is at nobody's expense but "
             + "his own.");
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      // ...
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke",
                              "or a vast practical joke, though t") }));
      stream.close();
 
      // negative
      URLConnection connection =
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

5)다수의 단언문(assert)

하나의 단위 테스트는 하나의 단언문으로 가는 것이 좋다. 때때로 단일 테스트에 다수의 단언문이 필요하긴 하지만 너무 많은 단언문을 가진다면 테스트 케이스를 두 개 이상을 포함하고 있는지 의심해봐야한다.

 

위 테스트에서는 어떠한 입력값에 대한 테스트와 어떠한 매칭도 되지 않는 테스트를 하나의 메서드 내에 작성하였음으로 두 개의 테스트로 분리 가능하다.

 

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
public class SearchTest {
   private static final String A_TITLE = "1";
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() 
         throws IOException {
      InputStream stream = 
            streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
      stream.close();
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      Search search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      inputStream.close();
   }
   // ...
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

6)테스트와 무관한 세부 사항들

위에서는 테스트와 부관한 로그를 끄는 코드, 스트림을 사용 후에 닫은 코드등 종단 관심이 아닌 횡단 관심에 해당되는 코드가 산재되어 있다. 이러한 코드는 @Before,@After 등과 같은 메서드로 분리가능하다.

 

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
public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      Search search = new Search(stream, "practical joke", A_TITLE);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      stream = connection.getInputStream();
      Search search = new Search(stream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
   }
   // ...
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

7)잘못된 조직

테스트에서 어느 부분들이 준비(Arrange), 실행(Act), 단언(Assert) 부분인지 아는 것은 테스트를 빠르게 인지할 수 있게 한다. 이 조직은 "AAA"조직이라 불린다. 보통 이러한 블럭은 개행으로 분리한다.

 

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
public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      Search search = new Search(stream, "practical joke", A_TITLE);
      search.setSurroundingCharacterCount(10);
 
      search.execute();
 
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      stream = connection.getInputStream();
      Search search = new Search(stream, "smelt", A_TITLE);
 
      search.execute();
 
      assertTrue(search.getMatches().isEmpty());
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
 
cs

 

 

TDD - JUnit 테스트 코드 구성 방법.(테스트 코드 조직)

이번 포스팅에서 다루어볼 내용은 테스트 코드를 잘 조직하고 구조화 할 수 있는 JUnit 기능을 살펴볼 것이다. 포스팅에서 다룰 내용은 아래와 같다. 준비-실행-단언을 사용하여 테스트를 가시적이고 일관성 있게..

coding-start.tistory.com

 

8)암시적 의미

각 테스트가 분명하게 대답해야 할 가장 큰 질문은 "왜 그러한 결과를 기대하는 가?"이다. 테스트 코드를 보는 누군가에게 테스트 준비와 단언 부분을 상호 연관 지을 수 있게 해야한다. 단언이 기대하는 이유가 분명하지 않다면 코드를 읽는 사람들은 그 해답을 얻기 위해 다른 코드를 뒤져 가며 시간을 낭비할 것이다.

 

returnsMatchesShowingContextWhenSearchStringInContent 테스트는 이름만 딱 봐도 특정 컨텐츠 안에 특정 문자열이 포함되있다면 컨텍스트를 가지는 Matches를 리턴한다라는 것을 파악할 수 있다. 하지만 단언의 결과는 테스트 코드를 보는 사람들로 하여금 이해하기 힘들며 직접 하나하나 따져봐야하는 결과이다.("or a vast practical joke, though t") 

 

우리는 의미없는 문장을 넣어 이해하기 쉬운 기대 결과값을 만들어 낼 수 있다. 또한 URLConnection은 비용이 어느정도 있는 객체이기 때문에 굳이 사용할 필요가 없이, 외부환경과 분리해서 임의의 텍스트를 넣어 초기화하였다.

 

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
public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("rest of text here"
            + "1234567890search term1234567890"
            + "more rest of text");
      Search search = new Search(stream, "search term", A_TITLE);
      search.setSurroundingCharacterCount(10);
 
      search.execute();
 
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, 
                    "search term"
                    "1234567890search term1234567890") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() {
      stream = streamOn("any text");
      Search search = new Search(stream, "text that doesn't match", A_TITLE);
 
      search.execute();
 
      assertTrue(search.getMatches().isEmpty());
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

여기까지 간단히 테스트 코드에 대한 리팩토링을 다루어봤다. 사실 리팩토링 능력은 경험의 차이가 아주 큰 것 같다. 테스트 코드를 짜는 습관을 들이다 보면 나도 리팩토링을 잘하는 날이 오겠지..

posted by 여성게
:
Web/TDD 2019. 9. 9. 16:41

 

이번 포스팅에서는 목(Mock) 객체를 사용하여 테스트하기 힘든, 혹은 외부환경과 의존성을 끊는 테스트를 하기 위한 방법을 간단하게 다루어 볼 것이다. 여기서 다루는 목(Mock)객체는 정말 단순한 수준의 예제이다. 결과적으로 이번 포스팅의 목적은 목객체는 무엇이고 왜 사용하는 지에 대한 내용이 될 것 같다.

 

지금 진행할 예제는 크게 코드 내용 자체를 알필요?는 없을 것 같다. 이 말은 우리가 개발하며 테스트를 작성할 때, 코드의 내용을 몰라도 된다는 말이 아니다. 테스트 작성은 당연히 코드의 내용을 빠삭히 알고 작성해야 하는 테스크이기 때문이다. 필자가 말하는 "알 필요는 없다"라는 것은 이번 포스팅은 독자들과 같이 애플리케이션을 개발하며 테스트를 작성하는 포스팅이 아니고 어느 순간에 목객체를 사용해야하냐를 다루는 문제이기 때문이다.

 

<위도경도를 이용한 주소정보 받아오기>

아래의 코드는 제목그대로 위도경도를 받아 특정 API를 호출해 해당 위도경도에 위치한 위치정보를 받아오는 코드이다.(효율적인 코드라고는 말할 수 없다. 그냥 흐름만 보자.)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class AddressRetriever {
   public Address retrieve(double latitude, double longitude)
         throws IOException, ParseException {
      String parms = String.format("lat=%.6flon=%.6f", latitude, longitude);
      String response = new HttpImpl().get(
        "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&"
        + parms);
 
      JSONObject obj = (JSONObject)new JSONParser().parse(response);
 
      JSONObject address = (JSONObject)obj.get("address");
      String country = (String)address.get("country_code");
      if (!country.equals("us"))
         throw new UnsupportedOperationException(
            "cannot support non-US addresses at this time");
 
      String houseNumber = (String)address.get("house_number");
      String road = (String)address.get("road");
      String city = (String)address.get("city");
      String state = (String)address.get("state");
      String zip = (String)address.get("postcode");
      return new Address(houseNumber, road, city, state, zip);
   }
}
cs

 

위와 같은 코드가 있다. 간단히 코드내용을 설명하면, retrieve 메소드에 위도와 경도 인자를 받아서 해당 인자를 이용하여 특정 API를 호출하고 위치에 대한 정보를 받아오고 있다. 만약 이 메소드를 단위 테스트하기 위해 걸리는 사항이 있나? 필자는 여기서 하나를 꼽자면 "외부 서버에 위치한 API를 호출하는 부분"를 얘기하고 싶다. 이런 부분은 우리가 통제할 수 있는 환경이 아니다. 즉, 우리의 테스트 코드와는 상관없이 API 트래픽이 높아 결과를 받아오는데 시간이 걸릴 수 있고, 최악으로는 API서버가 다운되고 API호출 결과로 예외가 발생할 수 있는 등의 우리가 통제할 수 없는 문제가 발생할 수 있다. 

 

테스트 관점에서 보면, API에서 적절한 결과값을 받아오는 것을 테스트할 수 있지만 "우리가 개발한 로직이 과연 기대한대로 동작하는 가?"를 테스트 해볼 수 있다. 물론 전자는 직접 API를 호출하여 결과값을 받아오는 테스트 코드를 작성할 수 있지만 후자는 굳이 API호출 동작 자체가 필요하지 않을 수 있다. 

 

그렇다면 직접 API를 호출할 필요가 없는 테스트지만 특정 API를 호출하고 있는 로직이 존재하는 코드를 테스트하기 위해서는 어떻게 할까?

이럴때 이용하는 것이 목(Mock) 객체이다. 일종의 스텁객체라고도 볼 수 있을 것 같다. 이러한 객체를 사용하면서 생기는 이점은 무엇일까?

 

  • 외부환경을 신경쓸 필요가 없이 비지니스로직의 성공유무를 테스트할 수 있다.
  • 외부환경에 따라 테스트가 빨리 끝날 수도 있지만, 외부환경이 갑자기 트래픽이 몰리는 시간이라면 테스트가 느리게 끝날 수도 있다. 하지만 목(Mock)객체를 사용한다면 직접 API를 호출하여 결과를 받아오는 것이 아니기 때문에 빠른 테스트 시간을 유지할 수 있다.
  • 테스트의 복잡도를 낮춰준다.

하지만 목객체를 이용하기 전에 위의 코드는 조금 문제점이 있다. API를 호출하는 객체(HttpImpl클래스)가 메서드 내부에서 초기화되고 있다는 것이다. 이것은 목객체를 이용한 테스트 코드를 작성할 수 없다. 우리는 "의존주입"이라는 것을 이용하여 목객체를 이용할 것이기 때문에 간단하게 프로덕 코드의 설계를 변경할 것이다.

 

"의존주입"을 이용하게 되면 생기는 장점은 아래와 같다.

 

  • 해당 클래스의 메서드는 실제 비지니스로직을 수행하는 HttpClient 객체가 목객체인지 혹은 실제 API를 호출하는 객체인지를 알 필요가 없어진다.

특정 오브젝트와의 결합도를 인터페이스(Http라는 인터페이스)를 이용하여 낮춰버렸기 때문에 이 인터페이스의 구현체로 Mock객체를 주입하던 실제 API를 호출하게 되는 HttpClient(HttpImpl 클래스) 객체를 넣던 해당 클래스는 신경쓸 필요가 없어진다.

 

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
public class AddressRetriever {
   private Http http;
 
   public AddressRetriever(Http http) {
      this.http = http;
   }
 
   public Address retrieve(double latitude, double longitude) throws IOException, ParseException {
      String parms = String.format("lat=%.6f&lon=%.6f", latitude, longitude);
      String response = http.get("http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" + parms);
 
      JSONObject obj = (JSONObject)new JSONParser().parse(response);
 
      JSONObject address = (JSONObject)obj.get("address");
      String country = (String)address.get("country_code");
      if (!country.equals("us"))
         throw new UnsupportedOperationException(
               "cannot support non-US addresses at this time");
 
      String houseNumber = (String)address.get("house_number");
      String road = (String)address.get("road");
      String city = (String)address.get("city");
      String state = (String)address.get("state");
      String zip = (String)address.get("postcode");
      return new Address(houseNumber, road, city, state, zip);
   }
}
cs

 

위 코드와 같이 특정 API 호출을 담당하는 객체를 인스턴스 변수로 선언하고 생성자에서 의존주입을 하고 있다.

 

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
public class AddressRetrieverTest {
   @Test
   public void answersAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = (String url) -> 
         "{\"address\":{"
         + "\"house_number\":\"324\","
         + "\"road\":\"North Tejon Street\","
         + "\"city\":\"Colorado Springs\","
         + "\"state\":\"Colorado\","
         + "\"postcode\":\"80903\","
         + "\"country_code\":\"us\"}"
         + "}";
      AddressRetriever retriever = new AddressRetriever(http);
 
      Address address = retriever.retrieve(38.0,-104.0);
      
      assertThat(address.houseNumber, equalTo("324"));
      assertThat(address.road, equalTo("North Tejon Street"));
      assertThat(address.city, equalTo("Colorado Springs"));
      assertThat(address.state, equalTo("Colorado"));
      assertThat(address.zip, equalTo("80903"));
   }
 
   @Test
   public void returnsAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = new Http() {
         @Override
         public String get(String url) throws IOException {
            return "{\"address\":{"
               + "\"house_number\":\"324\","
               + "\"road\":\"North Tejon Street\","
               // ...
               + "\"city\":\"Colorado Springs\","
               + "\"state\":\"Colorado\","
               + "\"postcode\":\"80903\","
               + "\"country_code\":\"us\"}"
               + "}";
            }};
      AddressRetriever retriever = new AddressRetriever(http);
 
      Address address = retriever.retrieve(38.0,-104.0);
      
      assertThat(address.houseNumber, equalTo("324"));
      assertThat(address.road, equalTo("North Tejon Street"));
      assertThat(address.city, equalTo("Colorado Springs"));
      assertThat(address.state, equalTo("Colorado"));
      assertThat(address.zip, equalTo("80903"));
   }
}
cs

 

우리는 설계를 변경한 클래스 테스트 작성을 위와 같이 할 수 있다. (포스팅에 올리지는 않았지만 Http 인터페이스는 get라는 메서드 선언을 하고 있는 FunctionalInterface이다.)

 

테스트에서는 스텁 객체(Http)를 이용해 retrieve 메서드를 테스트하고 있다. 또 다른 방법으로 Mokito라이브러리를 이용한 Mock(목) 객체를 이용하여 테스트를 작성할 수도 있다.

 

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
public class AddressRetrieverTest {
   @Test
   public void answersAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = mock(Http.class);
      when(http.get(contains("lat=38.000000&lon=-104.000000"))).thenReturn(
            "{\"address\":{"
            + "\"house_number\":\"324\","
           // ...
            + "\"road\":\"North Tejon Street\","
            + "\"city\":\"Colorado Springs\","
            + "\"state\":\"Colorado\","
            + "\"postcode\":\"80903\","
            + "\"country_code\":\"us\"}"
            + "}");
      AddressRetriever retriever = new AddressRetriever(http);
 
      Address address = retriever.retrieve(38.0,-104.0);
      
      assertThat(address.houseNumber, equalTo("324"));
      // ...
      assertThat(address.road, equalTo("North Tejon Street"));
      assertThat(address.city, equalTo("Colorado Springs"));
      assertThat(address.state, equalTo("Colorado"));
      assertThat(address.zip, equalTo("80903"));
   }
}
cs

 

목 객체를 이용하면 스텁객체를 사용하는 것보다 더 쉽게 메서드 인자 검사등을 수행할 수 있기 때문에 더 정교하고 똑똑한 일회성 객체를 만들 수 있다.

 

목객체를 이용한 테스트는 비단 외부API를 호출하는 것에만 국한되지 않는다. 기타 비용이 큰 로직에 대해서도 목객체를 이용하여 효율적인 테스트 코드작성, 혹은 비즈니스 로직에 집중된 테스트 코드를 작성할 수 있게된다.

 

하지만 목(Mock) 객체를 이용하기 위해서 중요한 것이 있습니다.

 

  • 목을 사용한 테스트는 진행하길 원하는 내용을 분명하게 기술해야 한다.(메서드 이름등을 명확히 짓는다.)
  • 목이 프로덕 코드의 동작을 올바르게 묘사하고 있는가를 살펴보자.
  • 혹시나 목 객체가 실제 프로덕코드를 실행하고 있지는 않은가?(임시로 프로덕코드에 throw문을 넣어 예외가 발생하는지 테스트 해볼 수 있다.)
  • 프로덕 코드를 직접 적으로 테스트하고 있지 않는 다는 것을 기억하자. 목을 도입하면 테스트 커버리지에서 간극을 형성할 수 있음을 인지하고 실제 클래스의 종단 간 사용성을 보여주는 상위 테스트가 있는지 확인하자.

 

여기까지 간단하게 목객체를 사용하는 이유중 한가지를 다루어봤습니다. 실제 모키토를 이용하여 기술적으로 목객체를 이용하는 방법등을 포스팅 내용에 없기 때문에 별도 자료로 찾아봐야합니다.

posted by 여성게
:
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 여성게
:
Web/TDD 2019. 9. 5. 13:25

 

단위 테스트를 작성하는데 있어서 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 : 적시의

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

posted by 여성게
:
Web/TDD 2019. 9. 4. 22:20

 

이번 포스팅에서 다루어볼 내용은 테스트 코드를 잘 조직하고 구조화 할 수 있는 JUnit 기능을 살펴볼 것이다.

포스팅에서 다룰 내용은 아래와 같다.

 

  • 준비-실행-단언을 사용하여 테스트를 가시적이고 일관성 있게 만드는 방법
  • 메서드를 테스트하는 것이 아니라 동작을 테스트하여 테스트 코드의 유지 보수성을 높이는 방법
  • 테스트 이름의 중요성
  • @Before와 @After 애너테이션을 활용하여 공통 초기화 및 정리 코드를 설정하는 방법
  • 거슬리는 테스트를 안전하게 무시하는 방법

 

AAA로 테스트 일관성 유지

 

  1. 준비(Arrange) : 테스트 코드를 실행하기 전에 시스템이 적절한 상태에 있는지 확인한다. 객체들을 생성하거나 이것과 의사소통하거나 다른 API를 호출하는 것 등이다. 드물지만 시스템이 우리가 필요한 상태로 있다면 준비 상태를 생략하기도 한다.
  2. 실행(Act) : 테스트 코드를 실행한다. 보통은 단일 메서드를 호출한다.
  3. 단언(Assert) : 실행한 코드가 기대한 대로 동작하는지 확인한다. 실행한 코드의 반환값 혹은 그 외 필요한 객체들의 새로운 상태를 검사한다. 또 테스트한 코드와 다른 객체들 사이의 의사소통을 검사하기도 한다.
  4. 사후(After) : 때에 따라 테스트를 실행할 때 어떤 자원을 할당했다면 잘 정리되었는지 확인해야한다.

 

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
@FunctionalInterface
public interface Scoreable {
   int getScore();
}
 
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();
   }
}
 
public class ScoreCollectionTest {
   @Test
   public void answersArithmeticMeanOfTwoNumbers() {
      //Arrange
      ScoreCollection collection = new ScoreCollection();
      collection.add(() -> 5);
      collection.add(() -> 7);
      
      //Act
      int actualResult = collection.arithmeticMean();
 
      //Assert
      assertThat(actualResult, equalTo(6));
   }
}
 
cs

 

동작 테스트 vs 메서드 테스트

테스트를 작성할 때는 클래스 동작에 집중해야 하며 개별 메서드를 테스트한다고 생각하면 안된다. 은행의 ATM을 예로 들면, 그 클래스의 메서드에는 deposit(),withdraw(),getBalance() 메서드가 있다.(예금,출금,잔액조회) 

 

만약 위와 같은 메서드를 가진 클래스가 존재하고, 출금에 관한 테스트 코드를 작성한다고 생각해보자. 출금은 하나의 메서드로 구현이 되어 있다. 그러면 출금을 테스트하기 위해서는 딱 withdraw()라는 단일 메서드만 신경쓰면 될까? 아니다. 출금을 위해서는 계좌개설 이후에 먼저 입금이 되어있어야 가능하다. 여기서 말하고자 하는 바는 테스트 코드를 작성한다는 것은 하나의 단일 메서드를 테스트하기 위한 테스트 코드가 아닌 하나의 동작, 혹은 일련의 동작의 모음을 테스트 하기 위한 코드 작성으로 생각하여 전체적인 시각에서 테스트코드를 시작, 작성해야 한다는 것이다.

 

테스트와 프로덕션 코드의 관계

JUnit 테스트는 검증 대상인 프로덕션 코드와 같은 프로젝트에 위치할 수 있다. 하지만 테스트는 주어진 프로젝트 안에서 프로덕션 코드와 분리해야 한다. 

 

단위 테스트는 일방향성이다. 테스트 코드는 프로덕션 코드에 의존하지만, 그 반대는 해당하지 않는다. 프로덕션 코드는 테스트 코드의 존재를 모른다. 하지만 테스트를 작성하는 행위가 프로덕션 시스템의 설계에 영향을 주지 않는다는 것은 아니다. 더 많은 단위 테스트를 작성할수록 설계를 변경했을 때 테스트 작성이 훨씬 용이해지는 경우가 늘어날 수 있다.

 

테스트와 프로덕션 코드 분리

프로덕션 소프트웨어를 배포할 때 테스트를 함께 포함할 수 있지만, 대부분 그렇게 하지 않는다. 그렇게 하면 로딩하는 JAR 파일이 커지고 코드 베이스의 공격 표면(Attack surface)도 늘어난다.

 

  • 테스트를 프로덕션 코드와 같은 디렉터리 및 패키지에 넣기 : 구현하기 쉽지만 어느 누구도 실제 시스템에 이렇게 하지 않는다. 이 정책을 쓰면 실제 배포할 때 테스트 코드를 걷어 내는 스크립트가 필요하다. 클래스 이름으로 구별하거나 테스트 클래스 여부를 식별할 수 있는 리플렉션 코드를 작성해야 한다. 테스트를 같은 디렉터리에 유지하면 디렉터리 목록에서 뒤져야 하는 파일 개수도 늘어난다.
  • 테스트를 별도 디렉터리로 분리하지만 프로덕션 코드와 같은 패키지에 넣기 : 대부분은 이것은 선택한다. 이클립스와 메이븐 같은 도구는 이 모델을 권장한다. src 디렉토리와 별개로 클래스 패스에 test 디렉토리를 두고, 실제 테스트할 클래스의 패키지명을 동일하게 만들어 테스트 클래스를 작성한다. 이렇게 디렉토리와 패키지를 구성하면 각 테스트는 검증하고자 하는 대상 클래스와 동일한 패키지를 갖는다. 즉, 테스트 클래스는 패키지 수준의 접근 권한을 가진다.
  • 테스트를 별도의 디렉터리와 유사한 패키지에 유지하기 : test 디렉터리에 검증 클래스와는 조금 다른 패키지를 생성해 테스트 코드를 유지한다. 테스트 코드를 프로덕션 코드의 패키지와 다르게 하면 public 인터페이스만 활용하여 테스트 코드를 작성한다.

 

단일 목적의 테스트 작성

 

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
59
60
61
62
63
64
65
66
67
68
69
public class ProfileTest {
    
    private Profile profile;
    private Question question;
    private Criteria criteria;
    
    @Before
    public void init() {
        profile = new Profile("Kakao");
        question = new BooleanQuestion(1"Got bonuses?");
        criteria = new Criteria();
    }
 
   @Test
   public void matchAnswerFalseWhenMustMatchCriteriaNotMet() {
      
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.MustMatch));
     
      boolean matches = profile.matches(criteria);
  
      assertFalse(matches);
   }
   
   @Test
   public void matchAnswersTrueForAnyDonCareCriteria() {
       profile.add(new Answer(question, Bool.FALSE));
       criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare));
       
       boolean matches = profile.matches(criteria);
       
       assertTrue(matches);
   }
   
}
 
===============================================================================================
 
public class ProfileTest {
    
    private Profile profile;
    private Question question;
    private Criteria criteria;
    
    @Before
    public void init() {
        profile = new Profile("Kakao");
        question = new BooleanQuestion(1"Got bonuses?");
        criteria = new Criteria();
    }
   @Test
   public void matchAnswerFalseWhenMustMatchCriteriaNotMet() {
      
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.MustMatch));
     
      boolean matches = profile.matches(criteria);
  
      assertFalse(matches);
 
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare));
       
      boolean matches = profile.matches(criteria);
       
      assertTrue(matches);
   }
   
}
cs

 

만약 위에 같은 테스트 코드가 있는데, 비슷한 테스트 케이스이기에 아래와 같이 테스트 코드를 하나의 메소드로 합쳤다. 이렇게 코드를 리팩토링하면 @Test 애너테이션이 하나이기에 ProfileTest 인스턴스는 하나만 생성되고 @Before 애너테이션이 붙은 초기화코드도 한번만 초기화되면서 두개를 테스트 해볼 수 있다. 하지만 이러한 리팩토링은 아래와 같은 이점을 잃게 된다.

 

테스트를 분리하는 이점

  • Assert가 실패했을 때 실패한 테스트 이름이 표시되기 때문에 어느 동작에서 문제가 있는지 빠르게 파악할 수 있다.
  • 실패한 테스트를 해독하는 데 필요한 시간을 줄일 수 있다. JUnit은 각 테스트를 별도의 인스턴스로 실행하기 때문이다. 따라서 현재 실패한 테스트에 대해 다른 테스트의 영향을 제거할 수 있다.
  • 모든 케이스가 실행되었음을 보장할 수 있다. Assert가 실패하면 현재 테스트 메서드는 중단된다. Assert 실패는 java.lang.AssertionError를 던지기 때문이다.(JUnit은 이것을 잡아 테스트를 실패로 표시한다.) Assert 실패 이후의 테스트 케이스는 실행되지 않는다.

즉, 단일 목적을 가진 테스트로 나누는 것이 좋다.

 

일관성 있는 이름으로 테스트 문서화

테스트 케이스를 단일 메서드로 결합할수록 테스트 이름 또한 일반적이고 의미를 잃어 간다. 좀 더 작은 테스트로 이동할수록 각각은 분명한 행동에 집중한다. 또 각 테스트 이름에 더 많은 의미를 부여할 수 있다. 테스트하려는 맥락을 제안하기 보다는 어떤 맥락에서 일련의 행동을 호출했을 때 어떤 결과가 나오는지를 명시하는 것이 좋다.

 

예제로 행위 주도 개발(BDD, Behavior-Driven Development)에서 말하는 given-when-then 같은 양식을 사용할 수도 있다.

 

ex)givenSomeContextWhenDoingSomeBehaviorThenSomeResultOccurs(주어진 조건에서 어떤 일을 하면 어떤 결과가 나온다.)

 

위와 같은 메서드 이름은 너무 길수도 있다. 그러면 givenSomeContext부분은 제거하여 

 

ex)whenDoingSomeBehaviorThenSomeResultOccurs(어떤 일을 하면 어떤 결과가 나온다.)

 

어느 형식이든지 일관성을 유지하는 것이 중요하다. 주요 목표는 테스트 코드를 다른 사람에게 의미 있게 만드는 것이다. 그리고 주석이 없어도 개발자가 봤을 때, 흐름을 이해할 수 있는 혹은 맥락을 이해할 수 있는 테스크 코드를 작성하는 것이 좋다.

 

@Before와 @After 더 알아보기

연관된 행동 집합에 대해 더 많은 테스트를 추가하면 상당한 테스트 코드가 같은 초기화 부분을 가진다. @Before 메서드를 활용하면 공통적인 초기화 코드를 하나의 메서드로 추출할 수 있어 중복된 코드들을 막을 수 있다.

 

JUnit이 @Before와 @Test 메서드를 어떤 순서로 실행하는지 이해하는 것은 중요하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AssertMoreTest {
   
   @Before
   public void createAccount() {
      // ...
   }
   
   @After
   public void closeConnections() {
      // ...
   }
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}
cs

 

만약 위와 같은 테스트 클래스가 존재한다고 생각해보자. 실행 순서는 어떻게 될까?

 

  1. @Before
  2. @Test(depositIncreasesBalance)
  3. @After
  4. @Before
  5. @Test(hasPositiveBalance)
  6. @After

 

  1. @Before
  2. @Test(hasPositiveBalance)
  3. @After
  4. @Before
  5. @Test(depositIncreasesBalance)
  6. @After

테스트 코드는 위와 같은 순서로 실행된다. 사실 @Test는 어떠한 순서로 실행될지 알 수 없다. 물론 @FixMethodOrder(MethodSorters.NAME_ASCENDING)와 같은 애너테이션을 테스트 클래스에 붙여 메서드 이름의 내림차순,오름차순으로 순서를 지정할 수는 있다. 하지만 조금 특이한 것이 보인다. 초기화와 후처리 메서드가 매 테스트 메서드마다 실행된다는 점이다. 이것은 즉, @Test 메서드를 수행할 때마다, 테스트 클래스는 새로운 인스턴스를 생성하여 @Before,@After를 다시 실행한다는 것이다.

 

이것은 JUnit의 특징이며 중요한 성질이다. JUnit은 우리에게 독립된 테스트 실행을 제공해주는 것이다.

 

@BeforeClass와 @AfterClass 애너테이션

해당 애너테이션이 붙은 메서드는 어떤 테스트를 처음 실행하기 전에 한 번만 실행된다.

 

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
public class AssertMoreTest {
   @BeforeClass
   public static void initializeSomethingReallyExpensive() {
      // ...
   }
   
   @AfterClass
   public static void cleanUpSomethingReallyExpensive() {
      // ...
   }
   
   @Before
   public void createAccount() {
      // ...
   }
   
   @After
   public void closeConnections() {
      // ...
   }
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}
cs

 

실행 순서는 아래와 같다.

 

  1. @BeforeClass
  2. @Before
  3. @Test
  4. @After
  5. @Before
  6. @Test
  7. @After
  8. @AfterClass

테스트제외

만약 테스트에서 제외하고 싶은 메서드가 있다면 @Ignore 애너테이션을 붙여준다. 그리고 결과를 보면 스킵된 테스트 개수를 볼 수 있다.(왼쪽 상단의 Runs: 2/2 (1 skipped) )

 

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AssertMoreTest {
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Ignore
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}
cs

 

 

여기까지 테스트 코드를 어떻게 조직하며 내부적인 JUnit 동작 메커니즘을 다루어보았다. 사실 너무 간단한 내용만 다루어봤지만 이렇게 하나하나 다루다보면 테스트 코드 작성에 익숙해지는 날이 오지 않을까 싶다.

posted by 여성게
: