ballqs 님의 블로그
[Spring] 테스트 코드 심화(Controller , Service) 본문
이번에 다룰 내용은 예전에 테스트 코드를 간단하게 다뤘던 적이 있어서 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으로 빼서 처리하는 방법도 블로그에 작성해보자.
'코딩 공부 > Spring' 카테고리의 다른 글
[Spring] @Configuration 란? (0) | 2024.09.14 |
---|---|
[Spring] CORS란? (0) | 2024.09.14 |
[Spring] AOP 동작 흐름 및 패턴 이해 (0) | 2024.09.10 |
[Spring] Redis 적용 (Window 환경) (0) | 2024.09.08 |
[Spring] CustomException , ErrorCode 작성 및 적용 (0) | 2024.09.05 |