Programming/Spring framework

모킹 테스트 코드 작성 이슈 (2024-01-15)

최동훈1 2024. 1. 15. 21:19

플리보따리 에서 자동 로그인 부분을 테스트 코드 작성하던중 의문이 발생했다. 모킹이란 무엇일까 ?

결론부터 말하겠다. 

"A를 할건데, 이런 상황에선 B, C, D가 실행되어야해" 를 상정해두고 그게 잘 실행 되는지를 검증하는 것

이 문장이다. 

즉 코드를 설명 하자면 

package com.hositamtam.plypockets.user.controller;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;

import com.google.gson.Gson;
import com.hositamtam.plypockets.controller.UserController;
import com.hositamtam.plypockets.dto.LoginDto;
import com.hositamtam.plypockets.dto.LoginResponseDTO;
import com.hositamtam.plypockets.service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MockMvcBuilder;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

@ExtendWith(MockitoExtension.class)
public class UserControllerTest {
    @InjectMocks
    private UserController userController;
    @Mock
    private UserService userService;

    private MockMvc mockMvc;

    @BeforeEach
    public void init() {
        mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
    }

    @DisplayName("회원가입 성공")
    @Test
    void LoginSuccess() throws Exception {
        //given
        LoginDto request = request();
        LoginResponseDTO response =response();

        doReturn(ResponseEntity.ok(response)).when(userService)
                .loginUser(any(LoginDto.class));

        //when
        ResultActions resultActions = mockMvc.perform(
                MockMvcRequestBuilders.post("/users/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(request))
        );

        //then
        System.out.println(resultActions);

        MvcResult mvcResult = resultActions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.userId").value(response.getUserId()))
                .andExpect(jsonPath("$.nickname").value(response.getNickname()))
                .andReturn();
    }
    private LoginDto request() {
        return LoginDto.builder()
                .nickname("훈")
                .password("vdongv1620")
                .build();
    }

    private LoginResponseDTO response() {
        return LoginResponseDTO.builder()
                .userId(1L)
                .nickname("최동훈")
                .build();
    }
}

실제 내가 진행하고 있는 "플리보따리" 서비스 테스트 코드 화면이다.

위코드는 단위테스트를 구현한 것이다. 추후에 @WebMvcTest를 이용해서 일련의 과정들을 없애겠다. 

어떻게 보면 당연하게 생각할 수 있는데, ["A를 할건데, 이런 상황에선 B, C, D가 실행되어야해" 를 상정] 하는 부분이 위 코드에서 doReturn()을 사용한 부분으로 봐야하고, [그게 잘 실행 되는지를 검증] 이 부분이 .andExpect() 라고 작성한 부분이다. 

나는 이해할수 없었다. 아니 이렇게 당연한 것을 왜 테스트 해야되지 ? 였다. 개발자 커뮤니티에 물어본 결과

나중에 유지보수하다보면
당연한 결과가 안나오는 불상사가 나오기 때문에 당연한 테스트 코드도 중요하다고 생각해요 라는 답변을 들었다...

내가 이해한 게 맞는지 의문이다. 이부분은 추가로 공부해서 공유해야겠다.

 

추가 공부한것을 추가. 같이 프로젝트를 진행한 안나님과 디스코드를 하면서 코드리뷰를 하였다. 그래서 우리가 결론을 낸 사실은

1.컨트롤러 테스트는 서비스 로직이 들어가는 것이 아니기 때문에, "당연한것을 테스트" 한다고 느낀다.

2.그럼에도 불구하고 컨트롤러 테스트의 이유는 기존에 정의한 Json 리스폰스가 임의로 변경되는것을 방지하기 위해서이다. 즉, 만약 실수로 DTO의 수정이 일어날 경우 컨트롤러 테스트 코드에서 통과가 안되기 때문에, 기존에 정의해놓은 Response 반환값을 교정해야됨을 파악할 수 있다. 또한 실제 예외가 터졌을 경우에, 커스텀 익셉션을 이용하여서 Handler에 정의한 우리가 반환할 오류 메세지와 HTTP CODE가 정상적으로 반환되는지 파악 할 수 있다. 

3. 그런데 당연한 것을 정의하는 아래의 코드는 Controller의 기능을 테스트 하는 것이기 때문에, Service는 이미 지정된 기능을 수행하면 된다고 생각되기 때문에, 사용한 것이다. 

BDDMockito.given(contentService.searchContent(1L)).willReturn(content);

이렇게, Controller에서는 미리 Service에서 반환하는 값을 적절한 행동을 해야되 이렇게 상정한 것이다. 

4. "예외" 가 터지는 것을 테스트 하고 싶다면 컨트롤러가 아닌, 서비스에서 assertJ를 이용해서 테스트가 가능하다.

 

BDDMockito.given(contentService.searchContent(1L)).willThrow(new ContentNotFoundException());

5.만약  오류 발생시에 HTTP 상태코드와 오류 메세지가 정상적으로 반환되는지 알고 싶다면, 아래의 코드를 이용해서, Mockito가 Content가 들어왔을때, 내가 지정한 커스텀 익셉션이 무조건 반환된다 라고 상정을 하게 되면, 당연히 컨트롤러를 호출했을때에도, 익셉션이 발생하고, 익셉션 핸들러가 HTTP response로 오류메세지와 오류코드를 반환하게 됨으로, .andExpect를 이용해서 실질적인 테스트가 가능하다. 

 

@WebMvcTest(ContentController.class)
class ContentControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ContentService contentService;



    @Test
    @DisplayName("디지털 컨텐츠 내용을 조회한다.")
    void getContentDetailsTest() throws Exception {

        //A라는 행위를 했을때, B랑  C가 동작해야되  ->동작하는직 검증
        // given: 테스트 데이터 설정
        Content content = ContentFixture.DEFAULT.getContent(); // 데이터 설정
//        BDDMockito.given(contentService.searchContent(1L)).willThrow(new ContentNotFoundException());
        BDDMockito.given(contentService.searchContent(1L)).willReturn(content);
        // contentService.searchContent(1L) 호출시 1L에 해당하는 가상의 Content 객체인 content를 반환하도록 설정
        //지정한 이유 ? 서비스는 테스트할 필요 없음. ->그렇기 때문에 Service는 예상된대로 동작한다고 "상정" 한 다음에 진행 한 것임.
        //컨트롤러 테스트 이유는 : 코드가 상호규약에 잘 맞는지  JSON 키 벨류 값이나, 컨트롤러 반환되는 HTTP CODE가 기존이랑 변화가 없는지 확인하기 위함임.
        //파악하기 위한 이유임.
        //만약 예외가 터졌을 경우에, 내가 정의한 커스텀 익셉션과 그에 포함되는 오류 메세지가 정상적으로 출력되는 것으로 파악하고 싶다면, .willThrow 사용

        // when: 컨트롤러 엔드포인트 호출
        MvcResult result = mockMvc.perform( //HTTP 요청 수행
                MockMvcRequestBuilders.patch("/contents/{contentId}", 1L) // 요청을 생성하고 해당 URL로 patch 메소드 호출하는 시뮬레이션
                        .contentType(MediaType.APPLICATION_JSON)) // HTTP 요청 헤더에 Content-Type을 설정하여 서버에게 요청 본문이 JSON 형식임을 알림
                .andReturn(); // HTTP 요청을 싱해하고 결과를 반환


        // then: 컨트롤러 응답 검증
        mockMvc.perform(MockMvcRequestBuilders.patch("/contents/{contentId}", 1L)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()) // HTTP 응답의 상태 코드가 200 (OK)인지 검증
                .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // 응답의 Content-Type이 JSON 형식인지 검증
                .andExpect(jsonPath("$.results").exists()) // 응답 JSON에 'results' 필드가 존재하는지 검증
                .andExpect(jsonPath("$.results.likeCnt").value(content.getLikeCnt())) // 'results' 필드 아래의 'likeCnt' 값이 기대한 값과 일치하는지 검증
                .andExpect(jsonPath("$.results.totalVoteCnt").value(content.getTotalVoteCnt())) // 'results' 필드 아래의 'totalVoteCnt' 값이 기대한 값과 일치하는지 검증
                .andExpect(jsonPath("$.results.viewCnt").value(content.getViewCnt())); // 'results' 필드 아래의 'viewCnt' 값이 기대한 값과 일치하는지 검증


    }

    @Test
    @DisplayName("디지털 컨테츠에 좋아요를 등록한다.")
    void likesTest() throws Exception {

        // given: 테스트 데이터 설정
        Content content = ContentFixture.DEFAULT.getContent(); // 데이터 설정
        BDDMockito.given(contentService.likes(1L)).willReturn(content); // contentService.searchContent(1L) 호출시 1L에 해당하는 가상의 Content 객체인 content를 반환하도록 설정

        // when: 컨트롤러 엔드포인트 호출
        MvcResult result = mockMvc.perform( //HTTP 요청 수행
                        MockMvcRequestBuilders.patch("/contents/{contentId}", 1L) // 요청을 생성하고 해당 URL로 patch 메소드 호출하는 시뮬레이션
                                .contentType(MediaType.APPLICATION_JSON)) // HTTP 요청 헤더에 Content-Type을 설정하여 서버에게 요청 본문이 JSON 형식임을 알림
                .andReturn(); // HTTP 요청을 싱해하고 결과를 반환


        // then: 컨트롤러 응답 검증
        mockMvc.perform(MockMvcRequestBuilders.patch("/contents/likes/{contentId}", 1L)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()) // HTTP 응답의 상태 코드가 200 (OK)인지 검증
                .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // 응답의 Content-Type이 JSON 형식인지 검증
                .andExpect(jsonPath("$.results").exists()) // 응답 JSON에 'results' 필드가 존재하는지 검증
                .andExpect(jsonPath("$.results.likeCnt").value(content.getLikeCnt())) // 'results' 필드 아래의 'likeCnt' 값이 기대한 값과 일치하는지 검증
                .andExpect(jsonPath("$.results.totalVoteCnt").value(content.getTotalVoteCnt())) // 'results' 필드 아래의 'totalVoteCnt' 값이 기대한 값과 일치하는지 검증
                .andExpect(jsonPath("$.results.viewCnt").value(content.getViewCnt())); // 'results' 필드 아래의 'viewCnt' 값이 기대한 값과 일치하는지 검증


    }

}

우리 서비스 Content의 컨트롤러 테스트 전문.