우선 Controller Test에 관해 작성하기 전에 Mock 객체에 짚고넘어가겠다.
Mock 객체란? (단순히 말하자면 가짜 객체)
- 실제 객체를 만들어 사용하기에 시간, 비용 등의 Cost가 높은경우 사용
- 가짜객체를 만들어 가짜객체가 원하는행위를 하도록 정의하고(가짜객체를 DI)
- 타 컴포넌트에 의존하지 않는 순수한 나의 코드만 테스트하기 위해서 사용
컨트롤러에서의 Mock 객체 사용방법
- MockMvc를 통해 api를 호출하며 해당컨트롤러에서 의존하고 있는 객체를 Mock객체로 만들어 주입해준다.(@MockBean 어노테이션 사용)
- Mock 객체는 가짜객체이므로 리턴되는값이 없다. 따라서 given, when 등으로 원하는 값을 리턴 하도록 미리 정의해준다. -> 상황 가정
- 로직이 진행된후 해당 행위가 진행됐는지 verify를 통해 검증해준다.
- Application Context 완전하게 Start 시키지 않고 web layer를 테스트 하고 싶을 때 @WebMvcTest를 사용하는 것을 고려한다. Service, Repository dependency가 필요한 경우에는 @MockBean으로 주입받아 테스트를 진행 한다.
@SpringBootTest의 경우 모든 빈을 로드하기 때문에 테스트 구동 시간이 오래 걸리고, 테스트 단위가 크기 때문에 디버깅이 어려울 수 있다. Controller 레이어만 슬라이스 테스트 하고 싶을 때에는 @WebMvcTest를 쓰는게 유용하다.
@WebMvcTest(PostController.class) // controller 레이어만 테스트
public class PostControllerTest {
}
@MockBean
PostService postService;
@MockBean
EncrypterConfig encoderConfig;
@MockBean(JpaMetamodelMappingContext.class) // jpa metamodel must not be empty! 에러 방지
@WebMvcTest(UserController.class)
class UserControllerTest {
}
- @Autowired : 스프링이 관리하는 빈(Bean)을 주입 받는다
- MockMvc : 웹 API를 테스트 할 때 사용한다. 웹 애플리케이션을 애플리케이션 서버에 배포하지 않고도 스프링 MVC의 동작을 재현할 수 있는 클래스. 이 클래스를 통해 HTTP GET, POST,put,patch,delete 등에 대한 API 테스트를 할 수 있다.
- mvc.perform(MockMvcRequestBuilders.get("url 주소")) : MockMvc를 통해 url 주소로 HTTP GET 요청을 한다.
체이닝(Chaining)이 지원되어 여러 검증 기능을 이어서 선언할 수 있다.- .andExpect(MockMvcResultMatchers.status().isOk()) : mvc.perform의 결과를 검증한다.HTTP Header의 Status를 검증한다. 해당 코드에서는 isOK, 즉 200인지 아닌지 검증한다.
- .andExpect(jsonPath("$.resultCode").exists()) : 컨틀롤러 테스트에서 응답으로 반환되는 Json객체에 값이 있는 지 확인하는 메서드
- .andExpect(jsonPath("$.result.userId").value(1L)) : 틀롤러 테스트에서 응답으로 반환되는 Json객체에 값이 value(?) value 안에 있는 값과 일치하는지 테스트 메서드
우선 애노테이션을 만들어준다. 애노테이션 생성 시 @WithSecurityContext애노테이션을 붙여주어야 한다.
@WithSecurityContext 애노테이션은 스프링 시큐리티 테스트용 SecurityContext를 만들겠다는 것이라 생각하면 된다. factory라는 값을 필수로 입력해야 하는데, 해당 클래스에서 우리가 만든 @WithMockCustomUser애노테이션에게 새로운 SecurityContext를 생성해 전달해야 한다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "username";
String grade() default "ADMIN";
}
위에 @WithSecurityContext애노테이션에 전달 할 WithMockCustomUserSecurityContextFactory클래스를 만든다.
이 클래스는 WithMockCustomUserSecurityContextFactory 인터페이스를 구현하여 작성한다.
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
final SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
final UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(annotation.username(),
"password",
Arrays.asList(new SimpleGrantedAuthority(annotation.grade())));
securityContext.setAuthentication(authenticationToken);
return securityContext;
}
}
이제 @WithMockCustomUser애노테이션을 사용할 수 있다.
default 값이 모두 있으므로 아무 값을 입력하지 않고도 사용할 수 있다.
@Test
@DisplayName("포스트 삭제 성공")
@WithMockCustomUser
public void post_delete_success() throws Exception {
given(postService.delete(any(), any())).willReturn(postDeleteRequest.getId());
mockMvc.perform(delete("/api/v1/posts/" + postDeleteRequest.getId())
//...생략
}
조금 더 견고하고 정확한 테스트를 진행하기 위해서 가끔은 해당 테스트 안에서 특정 메소드를 호출했는지에 대해서 검증을 할 필요가 있습니다. 이를 위해 Mockito 에서는 verify() 라는 함수를 지원해준다.
private List<String> mock = mock(List.class);
@Test
public void verifyMethod_basic() {
String value = mock.get(0);
String value2 = mock.get(1);
verify(mock).get(0);
verify(mock, times(2)).get(anyInt());
verify(mock, atLeast(1)).get(anyInt());
verify(mock, atLeastOnce()).get(anyInt());
verify(mock, atMost(2)).get(anyInt());
verify(mock, never()).get(2);
}
verify(mock).method(param);
해당 Mock Object 의 메소드를 호출했는지 검증
verify(mock, times(wantedNumberOfInvocations)).method(param);
해당 Mock Object 의 메소드가 정해진 횟수만큼 호출됬는지 검증
verify(mock, atLeast(minNumberOfInvocations)).method(param);
해당 Mock Object 의 메소드가 최소 정해진 횟수만큼 호출됬는지 검증
verify(mock, atLeastOnce()).method(param);
해당 Mock Object 의 메소드가 최소 한번 호출됬는지 검증
verify(mock, atMost(maxNumberOfInvocations)).method(param);
해당 Mock Object 의 메소드가 정해진 횟수보다 적게 호출됬는지 검증
verify(mock, never()).method(param);
해당 Mock Object 의 메소드가 호출이 안됬는지 검증
@Nested
@DisplayName("조회")
class PostList {
@Test
@WithMockUser // 인증된 상태
@DisplayName("post 단건 조회 성공")
void post_단건_조회_성공() throws Exception {
//조회 시 응답
PostSelectResponse postEntity = PostSelectResponse.builder()
.id(1L)
.title("테스트 제목")
.body("테스트 내용")
.userName("김건우")
.createdAt(String.valueOf(LocalDateTime.now()))
.lastModifiedAt(String.valueOf(LocalDateTime.now()))
.build();
//Service의 조회 메서드 사용시 post entity 반환
when(postService.getPost(any()))
.thenReturn(postEntity);
String url = "/api/v1/posts/1";
mockMvc.perform(get(url)
.with(csrf()))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.id").value(1L))
.andExpect(jsonPath("$.result.title").exists())
.andExpect(jsonPath("$.result.title").value("테스트 제목"))
.andExpect(jsonPath("$.result.body").value("테스트 내용"))
.andExpect(jsonPath("$.result.userName").value("김건우"))
.andExpect(jsonPath("$.result.createdAt").exists())
.andExpect(jsonPath("$.result.lastModifiedAt").exists());
verify(postService, atLeastOnce()).getPost(any());
}
@Test
@DisplayName("최신순 정렬 페이징 조회")
@WithMockUser
public void 페이징_테스트() throws Exception {
PageRequest pageable = PageRequest.of(0, 5, Sort.Direction.DESC, "registeredAt");
mockMvc.perform(get("/api/v1/posts")
.param("page", "0")
.param("size", "5")
.param("sort", "registeredAt")
.param("direction", "Sort.Direction.DESC"))
.andExpect(status().isOk());
assertThat(pageable.getPageNumber()).isEqualTo(0);
assertThat(pageable.getPageSize()).isEqualTo(5);
assertThat(pageable.getSort()).isEqualTo(Sort.by("registeredAt").descending());
verify(postService, atLeastOnce()).getPost(any());
}
}
@Nested
@DisplayName("작성")
class PostInsert {
@Test
@WithMockUser // 인증된 상태
@DisplayName("포스트 작성 성공")
void post_success() throws Exception {
/**given**/
//포스트 작성 시 필요한 dto
PostAddRequest postRequest = PostAddRequest.builder()
.title("테스트 제목")
.body("테스트 내용")
.build();
//예상 응답값
PostAddResponse postAddResponse = PostAddResponse.builder()
.postId(1L)
.message("포스트 등록 성공")
.build();
/**when**/
when(postService.addPost(any(), any())).thenReturn(postAddResponse);
/**then**/
String url = "/api/v1/posts";
mockMvc.perform(post(url)
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(postRequest)))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.message").exists())
.andExpect(jsonPath("$.result.message").value("포스트 등록 성공"))
.andExpect(jsonPath("$.result.postId").exists())
.andExpect(jsonPath("$.result.postId").value(1L));
verify(postService, atLeastOnce()).addPost(any(), any());
}
@Test
@WithAnonymousUser // 인증 된지 않은 상태
@DisplayName("포스트 작성 실패 : 권한 인증 없음 ")
void 작성실패() throws Exception {
/**given**/
PostAddRequest postRequest = PostAddRequest.builder()
.title("테스트 제목")
.body("테스트 제목")
.build();
/**when**/
when(postService.addPost(any(), any()))
.thenThrow(new PostException(ErrorCode.INVALID_PERMISSION, ""));
/**then**/
mockMvc.perform(post("/api/v1/posts")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(postRequest)))
.andDo(print())
.andExpect(status().isUnauthorized());
verify(postService, never()).addPost(any(),any());
}
}
@Nested
@DisplayName("수정")
class PostUpdate {
@Test
@WithMockUser // 인증된 상태
@DisplayName("포스트 수정 성공")
void 수정_성공() throws Exception {
/**given**/
//요청 dto
PostUpdateRequest modifyRequest = PostUpdateRequest.builder()
.title("테스트 제목")
.body("테스트 제목")
.build();
//응답 객체
Post postEntity = Post.builder()
.id(1L)
.build();
PostUpdateResponse postUpdateResponse = new PostUpdateResponse("수정성공", postEntity.getId());
/**when**/
when(postService.updatePost(any(), any(), any()))
.thenReturn(postUpdateResponse);
/**then**/
mockMvc.perform(put("/api/v1/posts/1")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(modifyRequest)))
.andDo(print())
.andExpect(jsonPath("$.resultCode").value("SUCCESS"))
.andExpect(jsonPath("$.result.message").exists())
.andExpect(jsonPath("$.result.message").value("수정성공"))
.andExpect(jsonPath("$.result.postId").exists())
.andExpect(jsonPath("$.result.postId").value(1L))
.andExpect(status().isOk());
verify(postService, atLeastOnce()).updatePost(any(), any(), any());
}
@Test
@WithAnonymousUser // 인증 되지 않은 상태
@DisplayName("포스트 수정 실패 : 인증 실패")
void 수정_실패() throws Exception {
/**given**/
PostUpdateRequest modifyRequest = PostUpdateRequest.builder()
.title("테스트 제목")
.body("테스트 제목")
.build();
/**when**/
//INVALID_PERMISSION에러가 나타나는 상황이 주어질 것
when(postService.updatePost(any(), any(), any()))
.thenThrow(new PostException(ErrorCode.INVALID_PERMISSION, ""));
/**then**/
//putMappin url
String url = "/api/v1/posts/1";
mockMvc.perform(put(url)
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(modifyRequest)))
.andDo(print())
.andExpect(status().isUnauthorized());
verify(postService, never()).updatePost(any(), any(), any());
}
@Test
@WithMockUser
@DisplayName("포스트 수정 실패 : 작성자 불일치")
void 수정실패_작성자불일치() throws Exception {
/**given**/
PostUpdateRequest modifyRequest = PostUpdateRequest.builder()
.title("title_modify")
.body("body_modify")
.build();
/**when**/
when(postService.updatePost(any(), any(), any()))
.thenThrow(new PostException(ErrorCode.INVALID_PERMISSION));
/**then**/
mockMvc.perform(put("/api/v1/posts/1")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(modifyRequest)))
.andDo(print())
.andExpect(status().is(ErrorCode.INVALID_PERMISSION.getStatus().value()));
//verify(postService, never()).updatePost(any(), any(), any());
}
@Test
@WithMockUser
@DisplayName("포스트 수정 실패 : 포스트 없음")
void 수정_실패_포스트X() throws Exception {
/**given**/
PostUpdateRequest postUpdateRequest = PostUpdateRequest
.builder().title("제목").body("내용")
.build();
/**when**/
when(postService.updatePost(any(), any(), any()))
.thenThrow(new PostException(ErrorCode.POST_NOT_FOUND,"포스트 없음"));
/**then**/
String url = "/api/v1/posts/1";
mockMvc.perform(put(url)
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(postUpdateRequest)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.resultCode").value("ERROR"))
.andExpect(jsonPath("$.result.errorCode").value("POST_NOT_FOUND"))
.andExpect(jsonPath("$.result.message").value("포스트 없음")).andDo(print());
verify(postService, times(1)).updatePost(any(), any(), any());
}
@Test
@DisplayName("포스트 수정 실패 : 데이터베이스 에러")
@WithMockUser
public void post_update_fail4() throws Exception {
/**given**/
PostUpdateRequest postUpdateRequest = PostUpdateRequest
.builder().title("제목").body("내용")
.build();
/**when**/
when(postService.updatePost(any(), any(), any())).thenThrow(new PostException(ErrorCode.DATABASE_ERROR));
/**then**/
mockMvc.perform(put("/api/v1/posts/1")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(postUpdateRequest)))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.resultCode").value("ERROR"))
.andExpect(jsonPath("$.result.errorCode").value("DATABASE_ERROR"))
.andDo(print());
verify(postService, times(1)).updatePost(any(), any(), any());
}
}
@Nested
@DisplayName("삭제")
class PostDelete{
@Test
@WithMockUser
@DisplayName("삭제 성공 ")
void 삭제성공() throws Exception {
PostDeleteResponse postDeleteResponse = PostDeleteResponse.builder()
.postId(1l)
.message("삭제 성공")
.build();
when(postService.deletePost(any(), any())).thenReturn(postDeleteResponse);
String url = "/api/v1/posts/1";
mockMvc.perform(delete(url)
.with(csrf()))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.resultCode").exists())
.andExpect(jsonPath("$.resultCode").value("SUCCESS"))
.andExpect(jsonPath("$.result.message").value("삭제 성공"))
.andExpect(jsonPath("$.result.postId").value(1l));
verify(postService, times(1)).deletePost(any(),any());
}
@Test
@WithAnonymousUser
@DisplayName("포스트 삭제 실패 : 인증 실패")
void 삭제실패() throws Exception {
when(postService.deletePost(any(), any()))
.thenThrow(new PostException(ErrorCode.INVALID_PERMISSION, ""));
mockMvc.perform(delete("/api/v1/posts/1L")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser // 인증된 상태
@DisplayName("포스트 삭제 실패 : 작성자 불일치")
void 삭제실패_작성자불일치() throws Exception {
when(postService.deletePost(any(), any()))
.thenThrow(new PostException(ErrorCode.INVALID_PERMISSION, ""));
mockMvc.perform(delete("/api/v1/posts/1")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().is(ErrorCode.INVALID_PERMISSION.getStatus().value()));
}
@Test
@WithMockUser // 인증된 상태
@DisplayName("데이터베이스 에러")
void 데이터베이스() throws Exception {
when(postService.deletePost(any(), any()))
.thenThrow(new PostException(ErrorCode.DATABASE_ERROR, "데이터 베이스 에러"));
mockMvc.perform(delete("/api/v1/posts/1")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().is(ErrorCode.DATABASE_ERROR.getStatus().value()));
}
}