관리 메뉴

ballqs 님의 블로그

[Spring] 테스트 코드 심화(Controller , Service) 본문

코딩 공부/Spring

[Spring] 테스트 코드 심화(Controller , Service)

ballqs 2024. 9. 11. 12:26

이번에 다룰 내용은 예전에 테스트 코드를 간단하게 다뤘던 적이 있어서 Mock를 통해 Service , Controller 를 테스트 하는 방법에 대해 작성해볼까 한다.
예전에 작성한 Test 블로그 글 : https://ballqs.tistory.com/43


Given-When-Then 패턴

  • FIRST 원칙
    • Fast : 유닛 테스트는 빨라야 함
    • Isolated : 테스트는 각 테스트간에 독립적으로 실행해야함
    • Repeatable : 테스트는 환경에 상관없이 실행할 때마다 같은 결과를 만들어야 함
    • Self-validating : 테스트는 명확히 성공/실패로 구분하여 테스트 자체가 결과를 검증 할 수 있어야 함
    • Timely : 테스트는 개발간에 즉시 작성해야 함. 대표적으로 TDD 방법론이 있음

 

given

@Test
public void 영화_단건조회() {
    // given
    long movieId = 1L;
    given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));
    
    ...
}
  • given에서 자원을 주어줄 때, mock에 어떤 값이든(any) 할당하는 값 메소드
  • willRetrun으로 결과 값을 매핑하기 때문에 자원에는 어떠한 값이 매핑되어도 상관 없기 때문에 사용함
  • any(), anyLong(), anyString(), anyList(), anySet(), anyMap() 등이 있음

when

@Test
@DisplayName("영화 단건조회 테스트")
public void getMovieTest() {
    ...

    // when
    MovieResponse result = movieService.getMovie(movieId);

    ...
}
  • @InjectMocks 대상 객체는 mock 객체가 아님
  • @InjectMocks 대상은 테스트하려는 실제 객체를 생성하고, 그 객체의 의존성들만 mocking된 객체로 대체하는 것
  • 따라서 @InjectMocks 대상 객체에는 mock에 넣어주는 any() 가 아닌 실제 자원을 넣어줘야함

then

@Test
@DisplayName("영화 삭제 실패")
public void getMovieTest() {
    ...

    // then
    assertEquals("entity null error", exception.getMessage());
    verify(movieRepository, times(0)).delete(any(Movie.class));
}
  • assert: 테스트의 결과를 검증하는 데 사용하며, 테스트 예상한 결과와 실제 결과와 비교하여 테스트가 성공/실패를 결정
  • verify: 특정 메소드가 호출되었는지, 호출 횟수는 몇 번인지, 호출 순서는 어떤지 등을 검증하는 데 사용

throws

@Test
public void 영화단건_조회() {
    // given
    long movieId = 1L;
    given(movieRepository.findById(anyLong())).willReturn(Optional.empty());

    // when
    NullPointerException exception = assertThrows(NullPointerException.class, () -> movieService.getMovie(movieId));

    // then
    assertEquals("null error", exception.getMessage());
}
  • assertThrows 로 처리하며, 각 상황에 정의한 에러 메세지가 맞는지 확인함

void method

@Test
public void 영화_삭제() {
    // given
    long movieId = 1L;
    Movie movie = new Movie("재밌는영화", 2002);

    given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));
    doNothing().when(movieRepository).delete(any(Movie.class));

    // when
    movieService.removeMovie(movieId);

    // then
    verify(movieRepository, times(1)).delete(any(Movie.class));
}
  • Mocktito.doNothing 메소드로 아무것도 처리 하지 않겠다는 의미
  • verify와 함께 핵심 자원만 호출 되었는지 정도 확인 해주면 좋음

WebMvc Test

@WebMvcTest bean 목록(공식)

i.e. @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans.

  • @WebMvcTest는 전체 테스트 하는 것이 아니라 Web layer만을 테스트할 때 사용

Controller Test

@WebMvcTest(controllers = {CommentController.class})
public class CommentControllerTest {

    private MockMvc mvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private CommentService commentService;

    @SpyBean
    private JwtUtil jwtUtil;

    @Mock
    private AuthUserArgumentResolver authUserArgumentResolver;

    @Autowired
    private CommentController commentController;

    private String token;
    private AuthUser authUser;

    @BeforeEach
    public void setUp() throws Exception {
        // authUserArgumentResolver를 사용하기 위해 추가한 코드
        mvc = MockMvcBuilders.standaloneSetup(commentController)
                .setCustomArgumentResolvers(authUserArgumentResolver).build();

        this.authUser = new AuthUser(1L , "test@test.com" , UserRole.ADMIN);
        this.token = jwtUtil.createToken(1L , "test@test.com" , UserRole.ADMIN);
    }

    @Test
    public void saveComment_동작_완료() throws Exception {
        // given
        long todoId = 1L;
        CommentSaveRequest requestDto = new CommentSaveRequest("내용");

        String postInfo = objectMapper.writeValueAsString(requestDto);

        User user = User.fromAuthUser(authUser);
        CommentSaveResponse commentSaveResponse = new CommentSaveResponse(1L , "내용" , new UserResponse(user.getId(), user.getEmail()));

        given(authUserArgumentResolver.supportsParameter(any())).willReturn(true);
        given(authUserArgumentResolver.resolveArgument(any() , any() , any() , any())).willReturn(authUser);
        given(commentService.saveComment(any() , anyLong() , any())).willReturn(commentSaveResponse);

        // when
        ResultActions resultActions = mvc.perform(post("/todos/{todoId}/comments" , todoId)
                        .header(HttpHeaders.AUTHORIZATION , token)
                        .content(postInfo)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                );

        // then
        resultActions.andExpect(status().isOk()).andDo(print());

        verify(commentService , times(1)).saveComment(any() , anyLong() , any());
    }
}
  • 특정 컨트롤러나 웹 계층에 대한 단위 테스트 진행
  • 컨트롤러가 예상대로 작동하는지, 웹 요청과 응답이 제대로 이루어지는지 검증

Service Test

@ExtendWith(MockitoExtension.class)
class ManagerServiceTest {
    @Mock
    private TodoRepository todoRepository;
    @InjectMocks
    private ManagerService managerService;

    @Test
    void todo의_user가_null인_경우_예외가_발생한다() {
        // given
        AuthUser authUser = new AuthUser(1L, "a@a.com", UserRole.USER);
        long todoId = 1L;
        long managerUserId = 2L;

        Todo todo = new Todo();
        ReflectionTestUtils.setField(todo, "user", null);

        ManagerSaveRequest managerSaveRequest = new ManagerSaveRequest(managerUserId);

        given(todoRepository.findById(todoId)).willReturn(Optional.of(todo));

        // when
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () ->
            managerService.saveManager(authUser, todoId, managerSaveRequest)
        );

        // then
        assertEquals("담당자를 등록하려고 하는 유저가 일정을 만든 유저가 유효하지 않습니다.", exception.getMessage());
    }
}
  • 애플리케이션이 실제로 실행될 때처럼 빈(Bean)들이 초기화되고 설정된 환경에서 테스트를 수행
  • 실제 설정한 환경 구성에 따른 실제 데이터를 사용하기 때문에 실제로 등록된 데이터에 대한 실제 로직이 수행됨

Test Coverage

  • 애플리케이션의 테스트 케이스가 얼마나 충족되었는지를 나타내는 지표
  • 커버리지가 항상 100%가 될 필요는 없음. 사실상 불가함.
  • 커버리지 자체가 목적이 되기보다는, 중요한 로직과 엣지 케이스에 대한 충분한 테스트가 필요
  • 커버리지가 높다고 해서 항상 코드가 완벽하다는 보장은 없으며, 테스트의 품질이 더 중요
  • 테스트 코드 실행시 Run `xxxTest` with Coverage 로 실행
  • Coverage Tab 확인
  • Cover 확인
    • 초록 : 완전히 테스트 된 코드
    • 빨강 : 테스트되지 않은 코드
    • 노랑 : 부분적으로 테스트 된 코드

마무리

테스트 강의를 들으면서 실제로 해보니까 어떤식으로 짜는지 알것 같다.

다음엔 공통된 자원이 필요하면 static으로 빼서 처리하는 방법도 블로그에 작성해보자.