ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 우승멤버 등록, controller 테스트 코드 작성하기 1
    Backend/개발 2021. 8. 12. 02:37
    반응형

    TIL 38일차 플젝 

    잠시 지원할것 들도 있고 주말에는 노느라고 공부를 쉬었다!

    마요에 빨리 별 달고 아크샨 사진 넣어야되는데 테스트코드부분에서 걸려서 푸시를 못하고 있는게 .. 너무 마음에 걸린다

    그치만 기능 대충 만들려고 공부 시작한거 아니니까 빨리 테스트코드 안되는거 해결하고 글 마저 쓰고 기능 업데이트 해야겠다! 일주일 넘게 잡고있으려니 답답 ㅠㅠ


    대회 결과 - 우승자 등록 하기

    37일차: 우승 멤버 별표표시 글에 대한 세부적인 테스트와 예외처리 과정을 나타낸글이다.

    리그의 결과를 저장하기 위해 , 우승 멤버와 대회의 차수를 PostMapping으로 받아오려고 한다.

    입력 값우승 멤버 5명의 닉네임과 몇번째 대회인지를 나타내는 리그번호 이다.

     

    다음의 틀에 맞게 구현을 해볼 것이다. 1,2번에 맞는 예외처리를 하고 이에 맞는 ControllerTest를 작성해보려고 한다.

    //MemberController
        @PostMapping("/api/member/league-results/{leagueId}")
        public ResponseEntity<String> registerLeagueResults(@RequestParam("member") List<String> members,@PathVariable Long leagueId){
          // 1. 이미 리그 결과가 등록되었을 경우
                            throw new IllegalArgumentException("이미 등록된 리그 결과입니다.");
    
                // 2. 등록되지 않은 참가자가 있는 경우
                            throw new IllegalArgumentException("등록되지 않은 참가자 입니다.");
    
                // 올바른 경우
            memberService.updateWinMemberLeagueResult(members, leagueId);
            return new ResponseEntity<>("ok", HttpStatus.OK);
        }
    

     

    @RequestParam

    @Controller 컨트롤러로 사용할 클래스에서 사용할 수 있다.

    @RequestParam 어노테이션은 HttpServletRequestgetParameter() 와 같은 역할을 한다.

    다만, 지정한 키 값이 존재하지 않다면 BadRequest로 에러가 발생한다.

    

    @RequestParam("key")로 받아온다면, 요청할때 key=value, key=value1로 여러 번 넣어주거나key=value1,value2,value3 ,로 구분하여 한번에 넣어줄 경우 List로 바로 받아올 수 있다.

     

    Service

    //MemberService
            @Transactional
        public void updateWinMemberLeagueResult(List<String> members, Long leagueId){
            for(String member : members){
                Long accountId = findMemberIdBySummonerName(member);
                memberRepository.findById(accountId).ifPresent(
                        e-> {
                            e.getLeagueResults().add(leagueId);
                            memberRepository.save(e);
                        });
            }
        }
    
        @Transactional
        public boolean isExistLeagueResultByLeagueId(Long leagueId){
            if(memberRepository.findLeagueResultsByLeagueId(leagueId).isEmpty()) return false;
            return true;
        }

    MemberServiceupdateWinMemberLeagueResult(지난 글 참고)와 추가로 isExistLeagueResultByLeagueId를 구현했다.

     

    1) 이미 등록된 리그

    • 등록하려는 leagueId와 같은 값이 데이터베이스에 저장된 league_id 에 있는지를 불러오는 기능에 대한 부분이다.
    //MemberServiceTest
    @Test
    public void 등록되지않은_리그결과(){
      // given
      Long leagueId = 3L;
      // when
      boolean result = memberService.isExistLeagueResultByLeagueId(leagueId);
      // then
      assertFalse(result);
    }

    Service에 대한 테스트코드를 작성해보았다.

    혹시 이부분에 long이 음수값이 들어올때 다른 예상치못한 예외가 발생할까 했는데, 그때도 그냥 false로 결과가 잘 나온다.

     

    memberRepository.findLeagueResultsByLeagueId를 구현하는데 있어서 일반 JPA 함수를 사용하지 못했는데,

    // MemberEntity
        @ElementCollection(fetch = FetchType.EAGER)
        @CollectionTable(name="member_league_results", joinColumns = @JoinColumn(name="member_id "))
        private List<Long> leagueResults = new ArrayList<>();

    member entity에 leagueResult를 다음과 같이 ElementCollection으로 저장했기 때문에

    // MemberRepository
    @Query("select m from MemberEntity m join fetch m.leagueResults lr where lr = :leagueId")
    public List<MemberEntity> findLeagueResultsByLeagueId(@Param("leagueId") Long leagueId);

    join을 통해 불러와야한다고 한다.

    이때joinJoin fetch 가 쿼리가 차이가 생기는데

    Hibernate: 
        select
            memberenti0_.account_id as account_1_4_,
            memberenti0_.created as created2_4_,
            memberenti0_.modified as modified3_4_,
            memberenti0_.assists as assists4_4_,
            memberenti0_.deaths as deaths5_4_,
            memberenti0_.kills as kills6_4_,
            memberenti0_.last_game_played as last_gam7_4_,
            memberenti0_.lose as lose8_4_,
            memberenti0_.positions as position9_4_,
            memberenti0_.summoner_name as summone10_4_,
            memberenti0_.win as win11_4_,
            leagueresu1_.member_id as member_i1_3_0__,
            leagueresu1_.league_results as league_r2_3_0__ 
        from
            members memberenti0_ 
        inner join
            member_league_results leagueresu1_ 
                on memberenti0_.account_id=leagueresu1_.member_id 
        where
            leagueresu1_.league_results=?
    
    Hibernate: 
        select
            memberenti0_.account_id as account_1_4_,
            memberenti0_.created as created2_4_,
            memberenti0_.modified as modified3_4_,
            memberenti0_.assists as assists4_4_,
            memberenti0_.deaths as deaths5_4_,
            memberenti0_.kills as kills6_4_,
            memberenti0_.last_game_played as last_gam7_4_,
            memberenti0_.lose as lose8_4_,
            memberenti0_.positions as position9_4_,
            memberenti0_.summoner_name as summone10_4_,
            memberenti0_.win as win11_4_ 
        from
            members memberenti0_ 
        inner join
            member_league_results leagueresu1_ 
                on memberenti0_.account_id=leagueresu1_.member_id 
        where
            leagueresu1_.league_results=?
    Hibernate: 
        select
            leagueresu0_.member_id as member_i1_3_0_,
            leagueresu0_.league_results as league_r2_3_0_ 
        from
            member_league_results leagueresu0_ 
        where
            leagueresu0_.member_id=?
    Hibernate: 
        select
            leagueresu0_.member_id as member_i1_3_0_,
            leagueresu0_.league_results as league_r2_3_0_ 
        from
            member_league_results leagueresu0_ 
        where
            leagueresu0_.member_id=?
    Hibernate: 
        select
            leagueresu0_.member_id as member_i1_3_0_,
            leagueresu0_.league_results as league_r2_3_0_ 
        from
            member_league_results leagueresu0_ 
        where
            leagueresu0_.member_id=?
    Hibernate: 
        select
            leagueresu0_.member_id as member_i1_3_0_,
            leagueresu0_.league_results as league_r2_3_0_ 
        from
            member_league_results leagueresu0_ 
        where
            leagueresu0_.member_id=?
    Hibernate: 
        select
            leagueresu0_.member_id as member_i1_3_0_,
            leagueresu0_.league_results as league_r2_3_0_ 
        from
            member_league_results leagueresu0_ 
        where
            leagueresu0_.member_id=?

    fetchjoin은 한 쿼리에 다 ~불러오는데 join은 Member를 조회한 후 N만큼 쿼리를 더 날린다.

    이부분이 N+1 문제인거같다..

    지금 당장 쿼리 시간은 한 4초로 차이는 많이 없고, 무조건 리그 id와 연관된 멤버가 5명이기때문에 사실 상관 없을수도 있지만 N이 많아진다면 쿼리자체가 너무 많아지니까 문제가 될 것 같기도 하다...

    사아알짝 이해가 되는거같기도하고오오오ㅗ.. N+1은 이해가 될때까지 계쏙 이렇게 애매하게 글을 쓸 것 같다.. ^^ ㅠㅠ

    https://cobbybb.tistory.com/18



    2) 등록되지 않은 참가자

    등록되지 않은 참가자 부분은 이전에 만들어놓은

    // MemberService
        @Transactional
        public Long findMemberIdBySummonerName(String summonerName) throws NullPointerException {
            return memberRepository.findMemberEntityBySummonerName(summonerName).getAccountId();
        }

    다음과 같은 함수가 있어서, 이걸 사용했는데 글을 쓰기 일주일 전에 작성한 코드를 보니 잘못 작성한 코드가 눈에 보여서 ㅎ.,ㅎ;; 코드를 수정해보았다.

    // MemberController.registerLeagueResults 일부
    // 수정 전
    for( String member: members){
      Long accountId = memberService.findMemberIdBySummonerName(member);
      if(!memberService.findMemberByAccountId(accountId).isPresent())
        throw new IllegalArgumentException("등록되지 않은 참가자 입니다.");
    }
    // 수정 후
    for( String member: members){
      log.info("member : {}",member);
      try {
        memberService.findMemberIdBySummonerName(member);
      } catch( NullPointerException e){
        throw new IllegalArgumentException("등록되지 않은 참가자 입니다. :"+member);
      }
    }

    처음에는 accountId를 받아와서 다시 accountId로 MemberEntity를 조회했는데, 이미 NullpointerException으로 없는 경우 예외를 발생시키므로 수정 전과같이 코드를 짤 필요가 없었다 !!

    그런데 수정 후와 같이 try..catch를 사용하는 것도 별로 좋지 않은 것 같다.., 예외를 발생시키는 위치가 Controller와 Service중에 어디가 나은지, 둘다 써도 되는지 어떻게 GlobalExceptionHandler를 만들어내면 되는지.. 아직 와닿지가 않아서 고민중이다. @_@

    일단은 이전에 미리 플젝 뼈대를 만들어주신분(?)이 작성한 코드에 예외처리를 컨트롤러에서 throw new Illegal로 하고, view단에서 이 메세지를 그대로 보여주고 있어서 그대로 맞춰 구현해보고있다..

    Controller.registerLeagueResults

    // MemberController
        @PostMapping("/api/member/league-results/{leagueId}")
        public ResponseEntity<String> registerLeagueResults(@RequestParam("member") List<String> members,@PathVariable Long leagueId){
    
            log.info("members: {}", members);
            log.info("leagueId: {}", leagueId);
    
            //이미 등록되었을 경우
            boolean isExist = memberService.isExistLeagueResultByLeagueId(leagueId);
            log.info("isExist: {}",isExist);
            if(isExist)
                throw new IllegalArgumentException("이미 등록된 리그 결과입니다. leagueId:"+leagueId);
    
            for( String member: members){
                log.info("member : {}",member);
                try {
                    memberService.findMemberIdBySummonerName(member);
                } catch( NullPointerException e){
                    throw new IllegalArgumentException("등록되지 않은 참가자 입니다. :"+member);
                }
            }
    
            memberService.updateWinMemberLeagueResult(members, leagueId);
            return new ResponseEntity<>("ok", HttpStatus.OK);
        }

    테스트 코드 작성

    저번 경매 팀 생성 글에서 webmvctest와 슬라이스테스트에 대해서 잠깐 적었었는데, 제대로 이해하지 못했던 것 같다..(사실 아직도 ㅠㅠ)

    보통 MockMvc를 사용할 때 mokitowhen이나 BDDMokitogiven을 사용해서 service의 값을 지정해주고, mockMvc.andExpect와 같이 테스트를 해보는 것 같은데..

    글을 찾아보면 이 mokito는 미리 그 내부 단의 컨트롤러에 어떤 값을 넣었을 때 어떤 결과가 나오는지를 컨트롤러테스트를 작성하는 입장에서 다 알고 구현해야하니까? 좀 잘 알고써야한다는 글도 있었고... 하여튼 언제 왜 어떻게 쓰는지가 아직 하나도 머릿속에 정리가 되지 않는다!!!

    그렇지만 일단 써보면서 익혀야할 것 같아서.. 써보려는데 지금까지 구현한 serviceTest 코드는 잘 성공하는데, controller에서 예외처리를 테스트하려고 보니까 자꾸 예상처럼 안돼서..

    이부분을 좀 부딪히면서 겪어보고 다음에 제대로 정리해봐야겠다.. 오늘은 일단 주절주절 기록만 남겨놓겠습니다..

     

        @Test
        public void 이미_등록된_리그() throws Exception {
            given(memberService.isExistLeagueResultByLeagueId(2L))
                    .willReturn(true);
            List<String> members = Lists.newArrayList("롤x콩,영x동,Elxly,에x킴,Gx대".split(","));
            for (String member : members) {
                given(memberService.findMemberIdBySummonerName(member)).willReturn(1l);
            }
    
            mockMvc.perform(post("/api/member/league-results/2")
                    .param("member", "롤x콩,영x동,Elxy,에x킴,Gx대"))
                    .andReturn();
        }

    일단 leagueId:2를 넣으면 true, 멤버 이름 넣으면 모두 존재하도록 넣어주었다.

    mockMvc를 그냥 수행했을 때

    org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.IllegalArgumentException: 이미 등록된 리그 결과입니다. leagueId:2
    Caused by: java.lang.IllegalArgumentException: 이미 등록된 리그 결과입니다. leagueId:2
        at com.mayo.lol.controller.api.MemberApiController.registerLeagueResults(MemberApiController.java:101)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    

    throw new IllegalArgumentException을 발생시켰기 때문에 응답으로 보내질때 500 InternalServerError가 돼서 Nested exception이 되는건가..? nestedservletexception의 의미를 잘 모르겠다.. 컨트롤러에서 발생시킨 이 예외를 바로 처리해오지 못하는것 같아서, 뭔가 조치가 필요하답..!

     

    일단 성공하는 테스트케이스의 경우를 적어보겠다.

    1. 컨트롤러에서 throw 대신 return new ResponseEntity<>(HttpStatus.xx);

    그 전에 HTTP 상태코드를 어떻게할지 잘 정리된 글이 있어서 참고해서 다른 게시글로 정리해보았다.

     

    // MemberController.registerLeagueResults 코드 일부 생략
    
    /* 1.이미 등록된 리그   
    ** 400으로 하기엔 /:leagueId로 요청을 하고 있기 때문에 해당하는 요청에 맞는 리그가 이미 등록되어있어서 못하는 비즈니스 로직상 `409` 에러가 맞아보인다.
    */
    if(isExist)
      return new ResponseEntity<>("이미 등록된 리그 결과입니다.", HttpStatus.CONFLICT);
    
    /* 2.등록되지 않은 참가자  
    ** String member로 값에 맞게 요청을 했을 경우 400에는 맞지 않는다. `404` 에러 
    */
    catch( NullPointerException e){
        return new ResponseEntity<>("등록되지 않은 참가자 입니다.",HttpStatus.NOT_FOUND);
      }
    
    //3. 올바른 경우
    return new ResponseEntity<>("ok", HttpStatus.OK);
    

    이렇게 return new ResponseEntity<>()로 응답해주는 컨트롤러를 작성하고,

        @Test
        public void 이미_등록된_리그_HTTPSTATUS() throws Exception, IllegalArgumentException {
            given(memberService.isExistLeagueResultByLeagueId(2L))
                    .willReturn(true);
            List<String> members = Lists.newArrayList("롤xx대".split(","));
            for (String member : members) {
                given(memberService.findMemberIdBySummonerName(member)).willReturn(1l);
            }
    
            mockMvc.perform(post("/api/member/league-results/2")
                    .param("member", "롤xx대"))
                    .andExpect(status().isConflict())
                    .andDo(print());
    
        }
    
        @Test
        public void 등록되지_않은_회원_HTTPSTATUS() throws Exception, IllegalArgumentException {
            given(memberService.isExistLeagueResultByLeagueId(3L))
                    .willReturn(false);
            List<String> members = Lists.newArrayList("롤xx대".split(","));
            for (String member : members) {
                if(member.equals("롤.."))
                    given(memberService.findMemberIdBySummonerName(member)).willThrow(NullPointerException.class);
                else
                    given(memberService.findMemberIdBySummonerName(member)).willReturn(1L);
            }
    
            mockMvc.perform(post("/api/member/league-results/2")
                    .param("member", "롤xx대"))
                    .andExpect(status().isNotFound())
                    .andDo(print());
    
        }
        @Test
        public void 등록_성공_HTTPSTATUS() throws Exception, IllegalArgumentException {
            given(memberService.isExistLeagueResultByLeagueId(3L))
                    .willReturn(false);
            List<String> members = Lists.newArrayList("롤xx대".split(","));
            for (String member : members) {
                    given(memberService.findMemberIdBySummonerName(member)).willReturn(1L);
            }
    
            mockMvc.perform(post("/api/member/league-results/2")
                    .param("member", "롤xx대"))
                    .andExpect(status().isOk())
                    .andDo(print());
    
        }
    

    given에 예외에 맞는 리턴값, 예외를 만들어주어서 status().isConflict(), status().isNotFound(), status().isOk() 3개의 상황에 맞게 테스트 해보았다.

     

    처음 이 부분 테스트코드 통과를 했따..;ㅁ; 이렇게 힘들일인가..

    근데 이렇게 짜면 기존에 작성된 코드랑 컨벤션을 맞출수가 없다. ㅠㅠ throw 에러를 발생시키고 글로벌 에러 핸들러를 작성해봐야할 것 같다.


    throw new IllegalArgumentException

    그냥 throw를 발생시킨 상태로 테스트코드를 통과하려면

    @Test(expected = NestedServletException.class)
    public void 이미_등록된_리그_expected() throws Exception {
      given(memberService.isExistLeagueResultByLeagueId(2L))
        .willReturn(true);
      List<String> members = Lists.newArrayList("롤두.....대".split(","));
      for (String member : members) {
        given(memberService.findMemberIdBySummonerName(member)).willReturn(1l);
      }
    
      MvcResult mvcResult = mockMvc.perform(post("/api/member/league-results/2")
                                            .param("member", "롤....공대"))
        .andDo(print());
    }

    @Test(expected = NestedServletException.class) 를 넣어주면 테스트코드가 성공한다. 근데 이렇게하면 andDo(print()) 가 진행되지도 않고 그냥 그 전에 에러발생->nested맞아?맞네 성공 이러고 끝난다. 그래서 이건 아닌 것 같다.

     

    얼른 좀 더 찾아봐서 throw에 대해 전역적으로 에러 처리하고, 그거에 맞는 테스트코드 작성까지 하고.. 관련되서 찾아본 글들도 정리해봐야겠다.

    그 부분은 더 찾아보고 다음 글에 올려야겠다.

     

    그리고 다음에 swagger Spring Rest Docs 와 같은 API 문서 작성하는 툴들을 사용해봐야겠다.

    생각할점 - request param에서의 예외인 badrequest, 내가 만든 409,404예외 총 3가지 예외처리 필요 / 이전에 throw된 예외도 같이..




    글이 너무 지저분하다.. 나중에 내가 봐도 이해할 수 있을까 @_@ 에효 몰랑 그냥 ㄱ ㅣ록용인거로 ..

    출처

    @RequestParam

    @RequestParam List형태로 받을 때 주의점

    JPQL select query for ElementCollection

    예외처리핸들러 나중에 참고할 블로그 https://bcp0109.tistory.com/303?category=981824

    Springboot에서 ControllerAdvice를 테스트하는법

    REST API 관점에서 바라보는 HTTP 상태 코드(HTTP status code)

    댓글

Designed by Tistory.