ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 슬라이스테스트, Validation 유효성 검사
    Backend/개발 2021. 8. 28. 22:58
    반응형

    TIL 43일차 개인프로젝트

    RepositoryTest -> ServiceTest -> ControllerTest 순서대로 작성해보면서 참가자 생성부터 다시 해보려고한다.

    너무,, 가독성이 떨어져서 그냥 기록용 ㅠ_ㅠ

     

    Repository - @DataJpaTest

    JUnit5부터 @DataJpaTest에 @Transactional 과 @ExtendWith(SpringExtension.class)를 이미 포함하고 있다.

        @Test
        @DisplayName("참가자 등록 및 이름 조회")
        public void saveParticipantsTest() {
    
            // given
            final ParticipantsEntity entity = ParticipantsEntity.builder()
                    .summonerName("감귤or가씨")
                          ...
                    .comment("화이팅")
                    .build();
            final ParticipantsEntity participantsEntity = participantsRepository.save(entity);
    
            // when
            final Optional<ParticipantsEntity> nameResult = participantsRepository.findBySummonerName("감귤or가씨");
    
            // then
            assertTrue(nameResult.isPresent());
            assertThat(nameResult.get().getSummonerName()).isEqualTo("감귤or가씨");
    
    }

    Repository에서는 그냥 Entity로 저장하고조회했다.

     

    ServiceTest

    @ExtendWith(SpringExtension.class)
    public class ParticipantsServiceTest {
    
        @InjectMocks
        private ParticipantsService participantsService;
    
        @Mock
        private ParticipantsRepository participantsRepository;
    }

    테스트의 대상이 되는 service에 mock을 주입해줄 것이므로 @InjectMocks, repository는 mock bean으로 만들어 둘 것이므로 @Mock 어노테이션을 이용했다. (@Mock이 붙은 객체를 @InjectsMocks이 붙은 객체에 주입해준다 )

    등록 실패

        @Test
        @DisplayName("참가자 등록 실패 - 이미 존재하는 닉네임")
        public void findBySummonerName(){
    
            // given
            final ParticipantsSaveRequestDto dto = ParticipantsSaveRequestDto.builder()
                    .summonerName("감귤or가씨")
                          ...
                    .build();
            given(participantsRepository.findBySummonerName("감귤or가씨")).willReturn(Optional.of(ParticipantsEntity.builder().build()));
    
            // when
            final MayoException result = assertThrows(MayoException.class, ()->participantsService.save(dto));
    
            // then
            assertThat(result.getCode()).isEqualTo(ErrorCode.DUPLICATE_SUMMONER_NAME);
        }

    이미 등록된 사용자여서 등록이 실패하는 경우에는 repository에서 Optional<ParticipantsEntity>를 반환할 것이므로 Optional.of(ParticipantsEntity.builder().build()) 이렇게 빈 객체를 생성해주었다.

    등록 성공

     @Test
        @DisplayName("참가자 등록 성공")
        public void save(){
            //given
            final ParticipantsSaveRequestDto dto = ParticipantsSaveRequestDto.builder()
                    .summonerName("감귤or가씨")
                          ...
                    .build();
    
            final ParticipantsEntity participantsEntity = ParticipantsEntity.builder()
                    .id(1l)
                    .summonerName("감귤or가씨")
                          ..
                    .comment("")
                    .build();
    
            given(participantsRepository.findBySummonerName("감귤or가씨")).willReturn(Optional.empty());
            given(participantsRepository.save(any(ParticipantsEntity.class))).willReturn(participantsEntity);
    
            //when
            ParticipantsSaveResponseDto result = participantsService.save(dto);
    
            assertThat(result.getId()).isEqualTo(1l);
            verify(participantsRepository,times(1)).findBySummonerName("감귤or가씨");
            verify(participantsRepository,times(1)).save(any(ParticipantsEntity.class));
        }
    given(participantsRepository.findBySummonerName("감귤or가씨")).willReturn(Optional.empty());
    given(participantsRepository.save(any(ParticipantsEntity.class))).willReturn(participantsEntity);
    • given을 통해 repository에서 조회되는게없다는 Optional.empty()를 리턴해주었다.
    • given(participantsRepository.save(any(ParticipantsEntity.class))).willReturn(participantsEntity);
      • 이 코드에서 any(ParticipantsEntity.class)가 아니라 dto를 넣으면 nullPointerException이 생긴다. 실제로 when부분에서 사용하는 객체와 같다고 인식하지 않는 것 같다. [참고 : https://escapefromcoding.tistory.com/259]

    그래서 앞으로 @mock의 given값에는 any로 지정해줄 것이다.

     

    그리고 verify()를 쓰면 @mock부분에서 동작해야할 함수가 불려지는지를 테스트할 수 있다. 숫자를 0,2 로 바꾸면 실패하는걸 보니 테스트에 쓰기 좋은 함수인것 같다. -> BDDMockito를 쓴다면 then 사용 

     

    컨트롤러 테스트 - WebMvcTest

    @MockBean(JpaMetamodelMappingContext.class)

    • BaseTimeEntity로 JpaAuditing을 통해 엔티티에 createTime, modifiedTime을 넣어주었다. 이 때문에 @EnableJpaAuditing 어노테이션을 Application에 추가하였는데, @WebMvcTest에선 이를 인식하지 못해서 테스트 클래스에 다음 어노테이션을 추가해주었다.
    • +) Application.java에 @EnableJpaAuditing을 추가해주지 않고 따로 @EnableJpaAuditing, @Configuration 어노테이션을 설정한 config 클래스 파일을 만들면 위의 과정을 안해줘도된다

    415 Unsupported Media Type

        @Test
        @DisplayName("이미 등록된 사용자일 때 테스트")
        public void DuplicateSummonerNameErrorTest() throws Exception {
    
            final ParticipantsSaveRequestDto dto = ParticipantsSaveRequestDto.builder()
                    .summonerName("감귤or가씨")
                          ..
                    .build();
    
            given(participantsService.save(any(ParticipantsSaveRequestDto.class))).willThrow(new MayoException(ErrorCode.DUPLICATE_SUMMONER_NAME));
    
            mvc.perform(post("/participants")
                    .header(HttpHeaders.CONTENT_TYPE,"application/json")
                    .content(objectMapper.writeValueAsString(dto)))
                    .andExpect(status().isConflict());
        }

    이렇게 했더니 Resolved Exception:
    Type = org.springframework.web.HttpMediaTypeNotSupportedException 에러가 나왔다.

    HTTP 상태 415 - 지원되지 않는 Media Type ...

    테스트시에 content-type을 appication/json으로 지정해주어야 한다.

     

    반환할 responseDto를 만들어 objectMapper로 감싸줬다.

     

    유효성 검증

    final ParticipantsSaveRequestDto dto = ParticipantsSaveRequestDto.builder()
            .summonerName("감귤or가씨")
            .mainPosition("SUP")
            .subPositions("MID")
            .currentTier("silver2")
            .highestTier("silver2")
            .build();

    입력형태가 다음과 같을 때 데이터의 유효성을 검사하려고 한다.

    Validation

    implementation 'org.springframework.boot:spring-boot-starter-validation'

    일단 build.gradle에 디팬던시를 추가해주었다.

     

    img

    출처 Hibernate Validator 6.0.11.Final — JSR 380 Reference Implementation: Reference Guide

    데이터 검증이 여러 계층에 걸쳐서 이루어진다면, 동일한 내용에 대한 검증 로직이 중복될 것이다. 그리고 만약 계층간의 검증 로직이 불일치되어 오류가 생길지도 모른다.

    img

    출처 Hibernate Validator 6.0.11.Final — JSR 380 Reference Implementation: Reference Guide

    이를 해결하기 위해서 데이터 검증을 위한 로직을 도메인 모델 자체에 묶어서 표현하는 방법이다.

    Java의 Bean Validation은 어노테이션으로 데이터 검증에 대한 명세(방법 제시)를 하였다. hibernate는 이를 구현하는 Hibernate Validator를 제공한다.

    Java에서 제공해주는 제약조건과, Hibernate에서 추가적으로 제공하는 제약조건, 그리고 커스텀 제약조건을 만드는 것도 가능하다.

    제약 조건에 대한 유효성 검증

    ValidatorFactory에서 Validator를 가져와 validate()를 사용해 빈의 유효성을 검증

    제약조건을 위반한 내용을 Constraintiolation 인터페이스로 반환

    @Builder
    class Participants{
    
      // NotNull: notnull
      // NotEmpty: notnull not""
      // NotBlank: notnull not"" not " "
        @NotBlank(message = "닉네임을 입력해주세요")
        private String summonerName;
    
        @NotNull(message = "주 포지션을 입력해주세요.")
        @Pattern(regexp= "TOP|JUG|MID|ADC|SUP", message = "올바른 형식의 주포지션을 입력해주세요(TOP,JUG,MID,ADC,SUP)")
        private String mainPosition;
    
        @Pattern(regexp = "(TOP|JUG|MID|ADC|SUP)?(,(TOP|JUG|MID|ADC|SUP))*",message = "올바른 형식의 부포지션을 입력해주세요(TOP,JUG,MID,ADC,SUP)")
        private String subPositions;
    
        @NotNull(message = "현재 티어값을 입력해주세요")
        private String currentTier;
        @NotNull(message = "최고 티어값을 입력해주세요")
        private String highestTier;
    }
    
    
    public class ValidationTest {
    
        @Test
        void participantsValidtaionTest(){
            Participants participants = Participants.builder()
                    .summonerName("하하")
                    .mainPosition("JUG")
                    .subPositions("SUP,JUG,MDD")
                    .currentTier("silver2")
                    .highestTier("silver2")
                    .build();
    
            ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
            Validator validator = factory.getValidator();
            Set<ConstraintViolation<Participants>> constraintViolations = validator.validate(participants);
    
            assertThat(constraintViolations)
                    .extracting(ConstraintViolation::getMessage)
                    .containsOnly("올바른 형식의 포지션을 입력해주세요(TOP,JUG,MID,ADC,SUP)");
        }
    }
    

    다음과같이 테스트해보았다.

     

    매개변수에 대한 유효성 검사, 응답 값에 대한 유효성 검사는 는 ExecutableValidator를 사용한다.

    그런데 Spring에서는 Validatior.validate()보단 AOP방식으로 사용하기 때문에, 진입 지점에서의 유효성 검사를 명시하기 위한 @Validated를 사용한다.

    만약 제약조건에 위반되는 내용이 발견되면 ConstraintViolationException이 발생하게된다.

    또한 SpringMVC에서 Data Binding시점에 유효성 검사를 실행한다.

    더 자세한 사용법은 다음 블로그나 공식 문서를 참고하자.

     

        @PostMapping("/participants")
        public ResponseEntity<Long> saveParticipants(@RequestBody @Validated ParticipantsSaveRequestDto dto){
            return ResponseEntity.ok(participantsService.save(dto).getId());
        }

    이렇게 만든 validation형식을 ParticipantsSaveRequestDto로 받아오는 컨트롤러에 명시하고, 테스트를 해주었더니

    Resolved Exception:
                 Type = org.springframework.web.bind.MethodArgumentNotValidException

    다음과같은 Exception이 발생했다.(400 BadRequest)

    이를 핸들링해주려고 한다.

        @ExceptionHandler(value = MethodArgumentNotValidException.class)
        public ResponseEntity<ErrorResponse> handleValidationException (MethodArgumentNotValidException e){
            String detailMessage = e.toString();
            if(e.hasErrors()) detailMessage = e.getAllErrors().stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage)
                    .collect(Collectors.joining(","));
            ErrorResponse errorResponse = ErrorResponse.builder()
                    .message(detailMessage)
                    .status(HttpStatus.BAD_REQUEST)
                    .build();
            log.error(detailMessage);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(errorResponse);
        }
    if(e.hasErrors()) detailMessage = e.getAllErrors().stream()
      .map(DefaultMessageSourceResolvable::getDefaultMessage)
      .collect(Collectors.joining(","));

     

    MethodArgumentNotValidationException e는 e.errors에 validation에서 생긴 에러들을 리스트로(BindingResult) 저장하기 때문에 해당하는 원인을 모두 출력해서 메세지로 담아주었다.

        @Test
        @DisplayName("validation으로 유효성 검증 에러핸들링 테스트")
        public void parameterValidationTest() throws Exception {
    
            final ParticipantsSaveRequestDto validErrorDto = ParticipantsSaveRequestDto.builder()
                    .summonerName("감귤or가씨")
                    .mainPosition("")
                    .subPositions("MIDdd")
                    .currentTier("silver2")
                    .highestTier("")
                    .build();
    
            given(participantsService.save(any(ParticipantsSaveRequestDto.class))).willThrow(new MayoException(ErrorCode.DUPLICATE_SUMMONER_NAME));
    
            mvc.perform(post("/participants")
                    .contentType(MediaType.APPLICATION_JSON_VALUE)
                    .content(objectMapper.writeValueAsString(validErrorDto)))
                    .andExpect(status().isBadRequest());
        }
    
    ---
      MockHttpServletResponse:
               Status = 400
        Error message = null
              Headers = [Content-Type:"application/json"]
         Content type = application/json
                 Body = {"status":"BAD_REQUEST","code":null,"message":"올바른 형식의 주포지션을 ìž…ë ¥í•´ì£¼ì„¸ìš”(TOP,JUG,MID,ADC,SUP),올바른 형식의 부포지션을 ìž…ë ¥í•´ì£¼ì„¸ìš”(TOP,JUG,MID,ADC,SUP)"}
        Forwarded URL = null
       Redirected URL = null
              Cookies = []

    springboot상에서 utf-8처리가 안되는지 메세지가 잘 보이지 않지만 postman으로 실행해보니 잘 보이는 것을 테스트할 수 있었다.

     

    다음 링크-github 를 보면 APPLICATION_JSON_UTF-8이 deprecated되고, APPLICATION_JSON_VALUE를 사용하면 크롬과 같은 브라우저에서는 자동으로 인식해준다는 것 같다.

     

    NHN Cloud의 Validaiton관련 글에도 동적인 검사, 동적인 메세지 생성, 오류 처리, 클래스 단위 , 조건부 검사 등에 대한 예제가 나와있으니 참고해야겠다.

     

    Service나 Bean에서는 @Validated나 @Valid를 쓰는데, 컨트롤러에서는 @Valid를 추가하면 된다고 한다. 그리고데이터 유효성 검사가 중복으로 실행되지 않도록 성능에 미치는 영향을 유의해야한다.

    @Validated vs @Valid

    두개의 차이를 알아보니 @Valid는 제약 조건에 대해서 모두 검사하고, @Validated는 제약 조건에 대해 그룹을 만들어 원하는 속성만 유효성 검사를 할 수 있다.

    보통 컨트롤러에서 @RequestBody로 받아올 경우 모든 사항을 검사하는것이 맞으니 @Valid만 써도 된다고 적혀있는 것 같다. @Validated(ValidationGroups.group1.class와 같이 그룹을 만들고 @NotNull(groups={ValidationGroups.group1.class}) 같이 제약조건을 걸 때 그룹을 명시해줄 수있다. 이건 나중에 필요할때 더 찾아봐야겠다.

     

    ConstraintViolationException vs MethodArgumentViolationException vs BindResult

    그리고 에러 핸들링을 하는 방법중에 먼저 따로 global handler를 만들지 않을 땐 컨트롤러 내부 파라미터로 BindResult를 받고 그 안에서 핸들링하는 방식이 있다. 이건 간단한데 그냥 한번에 처리하고 싶어서 나는 글로벌핸들러에 추가했다.

     

    두가지 Exception 모두@Valid로 발생하는 예외를 받아오는 것 같은데, 일반적인건 MethodArgumentViolationException인 것 같아서 나는 이걸로 사양하려고 한다. 사실 내가 지금 만난 예외가 Method..라서 그런데 만약에 구현하다가 Constr..가 발생했는데 핸들링을 못하면 추가하거나 더 찾아봐야겠다 !

    더 복잡하게 영어로 나와있는데 발번역할까봐 무서워서 링크만 남긴다.

     

    => 이때 몰라서 넘겼던 유효성 검증 부분은 https://w97ww.tistory.com/102 여기 다시 정리했다.

    성공 테스트

        @Test
        public void successTest() throws Exception {
            final ParticipantsSaveRequestDto requestDto = ParticipantsSaveRequestDto.builder()
                    .summonerName("새로운참가자")
                    .mainPosition("TOP")
                    .currentTier("platinum2")
                    .highestTier("platinum2")
                    .build();
    
            final ParticipantsSaveResponseDto responseDto = ParticipantsSaveResponseDto.builder()
                    .id(1l)
                    .summonerName("새로운참가자")
                    .mainPosition("TOP")
                    .currentTier("platinum2")
                    .highestTier("platinum2")
                    .build();
    
            given(participantsService.save(requestDto)).willReturn(responseDto);
    
            mvc.perform(post("/participants")
                    .contentType(MediaType.APPLICATION_JSON_VALUE)
                    .content(objectMapper.writeValueAsString(requestDto)))
                    .andExpect(status().isCreated());
        }
    MockHttpServletResponse:
               Status = 200
        Error message = null
              Headers = [Content-Type:"application/json"]
         Content type = application/json
                 Body = {"id":1,"summonerName":"스마트몽크","mainPosition":"TOP","subPositions":null,"currentTier":"platinum2","highestTier":"platinum2","comment":""}
        Forwarded URL = null
       Redirected URL = null
              Cookies = []

    HttpStatus를 201이되도록 반환하게 변경해주었다.

    repository, service, controller까지 계층별로 테스트 해보기 성공했다!

     

    이렇게해서 만들어진 실제 코드는

    //controller 
        @PostMapping("/participants")
        public ResponseEntity<ParticipantsSaveResponseDto> saveParticipants(@RequestBody @Valid ParticipantsSaveRequestDto dto){
            log.info("participants 생성 요청 : {}", dto);
            return ResponseEntity.status(HttpStatus.ACCEPTED.CREATED)
                            .body(participantsService.save(dto));
        }
    //service
         @Transactional
        public ParticipantsSaveResponseDto save(@RequestBody ParticipantsSaveRequestDto dto) {
    
            if (participantsRepository.findBySummonerName(dto.getSummonerName()).isPresent())
                throw new MayoException(ErrorCode.DUPLICATE_SUMMONER_NAME,"이미 등록된 사용자입니다.");
    
            ParticipantsEntity saved = participantsRepository.save(dto.toEntity());
    
            return ParticipantsSaveResponseDto.builder()
                    .id(saved.getId())
                    .summonerName(saved.getSummonerName())
                    .mainPosition(saved.getMainPosition())
                    .subPositions(saved.getSubPositions())
                    .currentTier(saved.getCurrentTier())
                    .highestTier(saved.getHighestTier())
                    .build();
        }
    @Repository
    public interface ParticipantsRepository extends JpaRepository<ParticipantsEntity, Long> {
        Optional<ParticipantsEntity> findBySummonerName(String summonerName);
    }

    에고 복잡해라 ~_~

    참고자료 및 출처

    댓글

Designed by Tistory.