MSA 인프라 구축하기 with Azure(5)- TestCode

SeungJu(하늘하늘)·2022년 10월 11일
0

CI/CD 파이프라인 중 테스트와 정적 코드 분석은 코드의 품질을 높일 수 있는 좋은 방법입니다. 이번 실습에서는 먼저 테스트 코드 작성에 대해 알아보겠습니다.

테스트 코드에 대한 중요성은 인지하고 있었지만, 사실 실제로 프로젝트를 진행하면서는 SI사업의 특성상 시간과 리소스의 문제로 실제로 테스트 코드를 작성하진 않았었습니다. 그러다보니 이번에 테스트 코드 작성도 처음하고, 정적 코드 분석이란 개념도 처음 접하게 되었습니다. 이에 대해 처음에는 이해를 잘 못했었는데 어떤 오해를 했었고, 어떻게 이해를 했었는지까지 한번 자세하게 적어볼까 합니다.(아직도 잘못 이해하고 있는 것이 있을 수 있으니 댓글로 정정해주시면 더 좋을 것 같습니다.)

1. 테스트 코드

테스트는 Mockito를 이용한 슬라이스 테스트로 진행하였습니다. 슬라이스 테스트란 레이어별로 잘라서 단위테스트를 진행하는 것으로 레이어에서 Bean을 최소한으로 등록시켜 테스트하는 것입니다.
Mockito란 결과를 넣은 가짜 객체를 주입하여 테스트를 진행하게 도와주는 프레임워크입니다.

단위테스트를 진행할 때 @SpringBootTest로 테스트하지 않는 이유는 다음과 같습니다.

  1. 실제 어플리케이션의 모든 Bean을 올리기 때문에 무겁다.
  2. 테스트 단위가 크기때문에 디버깅이 어렵다.

레이어는 Controller와 Service로 나누었으며, Controller에서는 @WebMvcTest를 이용하였습니다.
@WebMvcTest는 Mvc를 테스트할 때에 웹을 직접 실행시키지 않고 테스트 요청과 응답을 테스트 하기 위한 어노테이션입니다.

테스트 레이아웃은 아래와 같이 잡았습니다.

여기서 Sample은 테스트 할 Entity의 예시를 Constant로 정의해 둔 곳입니다. 이를 활용해서 코드를 테스트할 것입니다.

2. 테스트 코드 작성

아래는 예시를 위해 코드의 일부분을 발췌한 것입니다.

ControllerTest

QuestionController.java

public class QuestionController {

    private final QuestionService questionService;

    @PostMapping("/question")
    public ResponseEntity registerQuestion(@RequestHeader("userId") String userId,
                                           @RequestBody QuestionRegisterRequest questionRegisterRequest){
        questionService.registerQuestion(userId, questionRegisterRequest); // a
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/question-show")
    public ResponseEntity<QuestionResponse> findShowQuestionById(@RequestParam Long id){

        return ResponseEntity.ok(questionService.findShowQuestionResponse(id));
    }
    
    @GetMapping("/question-show-by-userId")
    public ResponseEntity<List<QuestionListResponse>> findByUserId(@RequestHeader("userId") String userId){

        return ResponseEntity.ok(questionService.findByUserId(userId));
    }
}

QuestionControllerTest.java

@MockBean(JpaMetamodelMappingContext.class) // 1
@WebMvcTest(QuestionController.class) //2
public class QuestionControllerTest {

    @Autowired
    private MockMvc mockMvc; //3

    @MockBean
    private QuestionService questionService; //4

    private Question question;

    @BeforeEach //5
    void setUp(){

        question = Question.builder()
                .id(QUESTION_ID)
                .userId(QUESTION_USER_ID)
                .question(
                        QuestionContents.builder()
                                .title(QUESTION_TITLE)
                                .body(QUESTION_BODY)
                                .build()
                )
                .response(
                        Response.builder()
                                .response(
                                        ResponseContents.builder()
                                                .title(RESPONSE_TITLE)
                                                .body(RESPONSE_BODY)
                                                .build()
                                )
                                .build()
                )
                .displayStatus(QUESTION_DISPLAY_STATUS)
                .build();
    }
    @DisplayName("[회원] 문의사항 등록 API")
    @Test
    void registerQuestion() throws Exception{

        doNothing()
                .when(questionService).registerQuestion(anyString(), any()); //6

        ResultActions result = mockMvc.perform(
                post("/api/question/question")
                        .header("userId", QUESTION_USER_ID)
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new ObjectMapper().writeValueAsString(QUESTION_REGISTER_REQUEST))
        ); // 7

        result.andExpect(status().isNoContent()); //8
    }

    @DisplayName("[회원] 특정 문의사항 조회 API")
    @Test
    void findShowQuestionById() throws Exception {

        doReturn(
                question.convertQuestionResponse()
        ).when(questionService).findShowQuestionResponse(QUESTION_ID); //9


        ResultActions resultActions = mockMvc.perform(
                get("/api/question/question-show")
                        .accept(MediaType.APPLICATION_JSON)
                        .param("id",QUESTION_ID.toString())
        );

        resultActions.andExpect(status().isOk())
                .andExpect(jsonPath("$.question.title").value(QUESTION_TITLE))
                .andExpect(jsonPath("$.question.body").value(QUESTION_BODY))
                .andExpect(jsonPath("$.response.title").value(RESPONSE_TITLE))
                .andExpect(jsonPath("$.response.body").value(RESPONSE_BODY)); // 10
    }


    @DisplayName("[회원] 특정 유저 문의사항 조회 API")
    @Test
    void findByUserId() throws Exception {
        doReturn(List.of(QuestionListResponse.builder()
                .id(QUESTION_ID)
                .title(QUESTION_TITLE)
                .existResponse(true)
                .build()
        ))
                .when(questionService).findByUserId(QUESTION_USER_ID);

        ResultActions resultActions = mockMvc.perform(
                get("/api/question/question-show-by-userId")
                        .accept(MediaType.APPLICATION_JSON)
                        .header("userId", QUESTION_USER_ID)
        );

        resultActions.andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(1))
                .andExpect(jsonPath("$[0].id").value(QUESTION_ID))
                .andExpect(jsonPath("$[0].title").value(QUESTION_TITLE))
                .andExpect(jsonPath("$[0].existResponse").value(true)); // 11
    }
  1. 현재 프로젝트는 @EnableJpaAuditing를 Application에 선언해두었기 때문에 @EnableJpaAuditing를 대부분 Bean이 사용합니다. 하지만 WebMvc는 JPA관련 Bean을 불러오지 않기 때문에 해당 어노테이션을 통해 Bean을 주입해주어야 합니다.

    https://giron.tistory.com/127 를 참고해보면 Application위에 @EnableJpaAuditing를 선언하면 JPA가 자동으로 넣어주는 데이터가 null로 들어오는 문제가 생깁니다. 실제로 저도 CreatedDate와 ModifiedDate가 null로 오는 것 때문에 해당 데이터들은 검수를 하지 않았었습니다. 하지만 별도의 config를 빼서 선언하면 해결된다고 합니다.

  2. 테스트 할 컨트롤러를 지정합니다.
  3. MockMvc를 주입합니다. 애플리케이션을 서버에 배포하지 않고, 테스트용 MVC환경을 만들어서 요청과 응답을 테스트 할 수 있는 클래스입니다.
  4. QuestionService를 실제 Bean을 주입하는 것이 아닌 가짜 객체를 주입합니다. 가짜 객체를 주입하는 방법은 @Mock과 @MockBean이 있는데 SpringBoot Container가 테스트시에 필요하고, Bean이 컨테이너에 존재한다면 @MockBean, 그 외에는 @Mock을 사용하라고 합니다.
    ...라고는 하지만 사실 아직 경험이 부족해서인지 잘 이해가 되지는 않습니다. 일단 간단하게는 WebMvcTest에서는 @MockBean을 사용한다고 합니다. 조금더 공부가 필요한 부분입니다.
  5. 테스트 전에 수행하는 사전 메서드로, 현재는 question의 객체를 초기화합니다.
  6. 실제 QuestionController에는 questionService.registerQuestion를 호출하는 부분이 있습니다. (주석 a) 하지만 questionService는 MockBean으로 주입된 가짜 객체입니다. 따라서 실제로 questionService.registerQuestion의 로직을 타지 않기 때문에 그 결과로 어떤 결과가 나올지 정해주어야합니다. doNothing은 결과가 void이기 때문에 해당 결과가 뱉는 것이 없다는 것을 의미하는 것입니다. 어떤 값을 넣어도 void가 나오기때문에 매개변수로 anyString()과 any()를 전달하였습니다.
  7. mockMvc를 이용해서 요청을 테스트 하고 해당 응답을 저장합니다.
  8. 응답의 HttpStatus를 검사합니다. 결과값 반환은 별도로 없기 때문에 응답값만 올바르게 받았는지 체크합니다.
  9. questionService.findShowQuestionResponse(QUESTION_ID)의 결과값을 question.convertQuestionResponse()로 정해줍니다.
  10. 응답 값의 httpStatus와 반환 json의 결과를 예상한 값과 같은지 검증합니다.
  11. 응답 값의 json이 배열일 경우 이와 같이 검증할 수 있습니다.
ServiceTest

QuestionService.java

@Service
@RequiredArgsConstructor
public class QuestionService {

    private final QuestionRepository questionRepository;

    @Transactional
    public void registerQuestion(String userId, QuestionRegisterRequest questionRegisterRequest){
        questionRepository.save(Question.of(questionRegisterRequest.getQuestion(),userId));
    }

    public Question findShowQuestionById(Long id){

        return questionRepository.findByIdAndDisplayStatus(id, DisplayStatus.SHOW)
                .orElseThrow(()-> new CustomException(ApiMessage.NOT_EXIST_QUESTION));
    }

    public QuestionResponse findShowQuestionResponse(Long id){
        return findShowQuestionById(id).convertQuestionResponse();
    }

    public List<QuestionListResponse> findByUserId(String userId){
        return questionRepository.findAllByUserIdAndDisplayStatusOrderByCreatedDateDesc(userId,DisplayStatus.SHOW)
                .stream().map(Question::convertListResponse)
                .collect(Collectors.toList());
    }
}

QuestionServiceTest.java

@ExtendWith(value = MockitoExtension.class) // 1
public class QuestionServiceTest {

    @InjectMocks
    @Spy
    private QuestionService questionService; // 2

    @Mock
    private QuestionRepository questionRepository; // 3

    private Question question;

    @BeforeEach
    void setUp(){

        question = Question.builder()
                .id(QUESTION_ID)
                .userId(QUESTION_USER_ID)
                .question(
                        QuestionContents.builder()
                                .title(QUESTION_TITLE)
                                .body(QUESTION_BODY)
                                .build()
                )
                .response(
                        Response.builder()
                                .response(
                                        ResponseContents.builder()
                                                .title(RESPONSE_TITLE)
                                                .body(RESPONSE_BODY)
                                                .build()
                                )
                                .build()
                )
                .displayStatus(QUESTION_DISPLAY_STATUS)
                .build();
    }

    @DisplayName("문의 등록")
    @Test
    void registerQuestion() {
        questionService.registerQuestion(QUESTION_USER_ID, QUESTION_REGISTER_REQUEST); // 4

        verify(questionRepository).save(any()); // 5
    }

    @DisplayName("문의 ID 조회")
    @Test
    void findShowQuestionById() {
        given(questionRepository.findByIdAndDisplayStatus(QUESTION_ID, DisplayStatus.SHOW))
                .willReturn(Optional.ofNullable(question)); // 6

        Question question1 = questionService.findShowQuestionById(QUESTION_ID); // 7

        assertAll(
                ()->assertThat(question1.getId()).isEqualTo(QUESTION_ID),
                ()->assertThat(question1.getUserId()).isEqualTo(QUESTION_USER_ID),
                ()->assertThat(question1.getQuestion().getTitle()).isEqualTo(QUESTION_TITLE),
                ()->assertThat(question1.getQuestion().getBody()).isEqualTo(QUESTION_BODY),
                ()->assertThat(question1.getResponse().getResponse().getTitle()).isEqualTo(RESPONSE_TITLE),
                ()->assertThat(question1.getResponse().getResponse().getBody()).isEqualTo(RESPONSE_BODY),
                ()->assertThat(question1.getDisplayStatus()).isEqualTo(QUESTION_DISPLAY_STATUS)
        ); // 8
    }
    @DisplayName("문의 ID 조회했는데 등록된 ID가 없는 경우 Exception 발생")
    @Test
    void findShowQuestionById_not_exist_question() {
        given(questionRepository.findByIdAndDisplayStatus(2L,DisplayStatus.SHOW))
                .willThrow(new CustomException(ApiMessage.NOT_EXIST_QUESTION)); // 9

        assertThatThrownBy(()->questionService.findShowQuestionById(2L))
                .isInstanceOf(CustomException.class)
                .hasMessage("문의사항이 존재하지 않습니다."); // 10
    }

    @DisplayName("question response로 convert")
    @Test
    void findShowQuestionResponse() {
        given(questionRepository.findByIdAndDisplayStatus(QUESTION_ID, DisplayStatus.SHOW))
                .willReturn(Optional.ofNullable(question));

        QuestionResponse questionResponse = questionService.findShowQuestionResponse(QUESTION_ID);

        assertAll(
                ()->assertThat(questionResponse.getQuestion().getTitle()).isEqualTo(QUESTION_TITLE),
                ()->assertThat(questionResponse.getQuestion().getBody()).isEqualTo(QUESTION_BODY),
                ()->assertThat(questionResponse.getResponse().getTitle()).isEqualTo(RESPONSE_TITLE),
                ()->assertThat(questionResponse.getResponse().getBody()).isEqualTo(RESPONSE_BODY)
                );
    }

    @DisplayName("유저 문의 조회")
    @Test
    void findByUserId() {
        given(questionRepository.findAllByUserIdAndDisplayStatusOrderByCreatedDateDesc(QUESTION_USER_ID, DisplayStatus.SHOW))
                .willReturn(List.of(question));

        List<QuestionListResponse> response = questionService.findByUserId(QUESTION_USER_ID);

        assertAll(
                ()->assertThat(response.size()).isEqualTo(1),
                ()->assertThat(response.get(0).getId()).isEqualTo(QUESTION_ID),
                ()->assertThat(response.get(0).getTitle()).isEqualTo(QUESTION_TITLE),
                ()->assertThat(response.get(0).getExistResponse()).isEqualTo(true)
        ); // 11
    }
}
  1. Mockito를 이용해서 테스트를 진행한다는 의미입니다.
  2. @InjectMocks은 @Mock에서 만들어진 객체들을 사용해서 자신의 객체를 생성한다는 의미입니다. 예를들어 현재같은 경우 QuestionService에는 QuestionRepository가 주입되어야 하는데 이를 @Mock으로 만들어진 questionRepository를 사용합니다.
    @Spy는 진짜 객체를 주입합니다. 이 상황에서 Stubbing을 하지 않으면 기존 객체의 로직을 수행하고 실행한 값을, Stubbing을 하면 Stubbing 값을 리턴합니다.

    메서드 Stub이란 기존 코드를 대리하거나 흉내내는 것을 의미합니다. 간단하게 메서드의 값을 정해주는 행위를 Stub이라고 생각하면 될 듯 합니다.

  3. 위에 QuestionService에 InjectMocks에 Mock으로 사용하기 위해 가짜 객체를 주입합니다.
  4. questionService.registerQuestion(QUESTION_USER_ID, QUESTION_REGISTER_REQUEST)를 호출합니다. 해당 메서드의 결과값은 void라 별도의 값을 검증하지는 않고, 로직에 문제가 없는지 에러가 발생하지는 않는지 정도만 체크합니다.
  5. questionRepository.save(any())를 호출했는지 검증합니다.
  6. questionService.findShowQuestionById 내부 로직에는 questionRepository.findByIdAndDisplayStatus를 호출하는 부분이 있습니다. questionRepository는 가짜 객체이기 때문에 해당 메서드의 결과값을 정해줘야 합니다. questionRepository.findByIdAndDisplayStatus(QUESTION_ID, DisplayStatus.SHOW)의 결과는 Optional.ofNullable(question)라고 지정해주는 부분입니다.
  7. questionService.findShowQuestionById(QUESTION_ID)를 수행하고 결과를 question1에 담습니다.
  8. 호출한 결과를 예상한 결과와 맞는지 검증합니다.
  9. questionRepository.findByIdAndDisplayStatus(2L,DisplayStatus.SHOW)의 결과로 에러가 발생한다는 상황을 주입합니다.
  10. questionService.findShowQuestionById(2L)의 결과로 의도한 Exception이 발생되는지 확인합니다.
  11. 결과가 list일때 의도한 결과를 검증합니다.

위와 같은 방식으로 테스트 코드를 작성하면 다음 실습에서 진행할 jacoco와 SonarQube에서 코드 커버리지를 측정할 수 있습니다. 코드 커버리지란 테스트 코드가 애플리케이션의 어느 정도까지 검증했는지를 측정하는 수치입니다. 테스트코드를 꼼꼼하게 작성할수록 코드 커버리지가 올라가므로 테스트 코드는 꼼꼼하게 작성하는 것이 좋습니다. 다음 실습에서는 jacoco와 sonarQube를 활용하여 코드 커버리지 측정 및 정적 분석하는 방법에 대해 알아보겠습니다.

profile
나의 개발 세상

0개의 댓글