Spring Controller Test

김건우·2022년 12월 30일
0

Junit / TDD

목록 보기
3/4
post-thumbnail

우선 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를 쓰는게 유용하다.

특정 Controller 클래스만 지정하여 스캔 @WebMvcTest

@WebMvcTest(PostController.class) // controller 레이어만 테스트
public class PostControllerTest {

}

@MockBean

  • 말 그래도 가짜 객체 해당 단위 테스트에만 집중할 수 있도록 도와준다. 여기서는 서비스를 MockBean으로 선언하였고, 서비스 내 의존성 연결고리를 신경 안써도 되며 서비스의 호출, 결과를 임의로 조작하여 테스트를 지원한다(given(),thenReturn() 메서드 사용 예정 )
    @MockBean
    PostService postService;
    @MockBean
    EncrypterConfig encoderConfig;

@MockBean(JpaMetamodelMappingContext.class)

  • JPA의 Auditing 기능을 사용 시 그리고 이에 관련된 @EnableJpaAuditing 어노테이션을 메인 어플리케이션에 적용했을 때 어플리케이션 시작시에 @EnableJpaAuditing이 항상 같이 로드되는데
    @WebMvcTest 은 JPA 관련 Bean을 로드하지 않기 때문에 @MockBean(JpaMetamodelMappingContext.class)을 클래스 상단에 붙여준다면 jpa metamodel must not be empty! 테스트 에러를 막을 수 있다.
@MockBean(JpaMetamodelMappingContext.class) // jpa metamodel must not be empty! 에러 방지
@WebMvcTest(UserController.class)
class UserControllerTest {

}

Controller Test Method

  1. @Autowired : 스프링이 관리하는 빈(Bean)을 주입 받는다
  2. MockMvc : 웹 API를 테스트 할 때 사용한다. 웹 애플리케이션을 애플리케이션 서버에 배포하지 않고도 스프링 MVC의 동작을 재현할 수 있는 클래스. 이 클래스를 통해 HTTP GET, POST,put,patch,delete 등에 대한 API 테스트를 할 수 있다.
  3. mvc.perform(MockMvcRequestBuilders.get("url 주소")) : MockMvc를 통해 url 주소로 HTTP GET 요청을 한다.
    체이닝(Chaining)이 지원되어 여러 검증 기능을 이어서 선언할 수 있다.
  4. .andExpect(MockMvcResultMatchers.status().isOk()) : mvc.perform의 결과를 검증한다.HTTP Header의 Status를 검증한다. 해당 코드에서는 isOK, 즉 200인지 아닌지 검증한다.
  5. .andExpect(jsonPath("$.resultCode").exists()) : 컨틀롤러 테스트에서 응답으로 반환되는 Json객체에 값이 있는 지 확인하는 메서드
  6. .andExpect(jsonPath("$.result.userId").value(1L)) : 틀롤러 테스트에서 응답으로 반환되는 Json객체에 값이 value(?) value 안에 있는 값과 일치하는지 테스트 메서드

@WithSecurityContext (Spring security 적용 시)

우선 애노테이션을 만들어준다. 애노테이션 생성 시 @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())
        //...생략
    }

Verify Method Calls

조금 더 견고하고 정확한 테스트를 진행하기 위해서 가끔은 해당 테스트 안에서 특정 메소드를 호출했는지에 대해서 검증을 할 필요가 있습니다. 이를 위해 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 의 메소드가 호출이 안됬는지 검증

PostController 조회 단건/페이징

    @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());
        }

    }
  • 테스트 성공 🔽

PostController 작성 성공/실패

    @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());
        }
    }
  • 테스트 성공 🔽

PostController 수정 성공/실패

@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());
        }

    }
  • 테스트 성공 🔽

PostController 삭제 성공/실패

@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()));
    }
    }
  • 테스트 결과 🔽
profile
Live the moment for the moment.

0개의 댓글