본문 바로가기
Programming/TDD Project

Pull Request 017. 자유 게시글 여러 개 조회 기능 TDD로 구현하기

by JKROH 2023. 11. 29.
반응형

 자유 게시글과 관련해서 남은 기능은 크게 2개다. 하나는 페이지 전체 조회 기능이고, 하나는 검색 기능이 되시겠다.

 

 우선은 페이지 조회 기능을 구현해보고자 한다. 페이지 조회 기능의 로직은 다음과 같다.

 

  1. 게시글 페이지 조회 urn은 /free-board?page="pageNumber"의 형태다.
  2. FreeBoardController#getFreeBoardPage(@RequestParameter(value = "page")int page) 메서드를 통해 get 요청을 받는다. 해당 메서드는 FreeBoardService#getFreeBoardPage(int page) 메서드를 호출하고 받은 결과값을 반환한다. 이 때 HTTP 상태 코드는 200 OK다.
  3. FreeBoardService#getFreeBoardPage(int page) 메서드는 List<FreeBoardDto.Response>를 반환한다. FreeBoardQueryService#readPagedFreeBoard(int page) 메서드를 호출한다.
  4. FreeBoardQueryService#readPagedFreeBoard(int page) 메서드는 List<FreeBoard>를 반환한다. FreeBoardRepository#findAll(Pageable pageble) 메서드를 호출한 결과값에서 List<FreeBoard>를 뽑아와 반환한다.

 

 이렇게 놓고 봤을 때, 테스트 할 기능은 1, 3번이 있다. 2번의 경우, FreeBoardQueryService와 FreeBoardConverter를 호출할 뿐이기 때문에 별도의 테스트는 진행하지 않는다.

 

 먼저 3번의 테스트부터 만들어보자.

    @Test
    @DisplayName("여러 개의 자유 게시글을 읽어온다.")
    void testReadPagedEntity() {
        
    }

 

 우리가 해당 테스트 메서드를 통해 테스트해보고 싶은 점은 '특정 페이지 정보가 들어갔을 때, 해당 페이지 정보를 바탕으로 레파지토리에서 가져온 페이지가 포함하는 List와 메서드가 반환하는 List가 같은가?' 이다. 이러한 정보를 바탕으로 테스트 코드를 작성해보자.

    @Test
    @DisplayName("여러 개의 자유 게시글을 읽어온다.")
    void testReadPagedEntity() {
        //given
        int fakePageNumber = 1;
        Pageable fakePageable = PageRequest.ofSize(20).withPage(fakePageNumber);

        Page<FreeBoard> fakePage = new PageImpl<>(List.of(
                FreeBoard.builder().title("1").content("1").build(),
                FreeBoard.builder().title("2").content("2").build(),
                FreeBoard.builder().title("3").content("3").build(),
                FreeBoard.builder().title("4").content("4").build(),
                FreeBoard.builder().title("5").content("5").build()
        ));

        given((freeBoardRepository.findAll(fakePageable))).willReturn(fakePage);

        //when
        List<FreeBoard> foundList = freeBoardQueryService.readPagedEntity(fakePageNumber);

        //then
        assertThat(foundList).isEqualTo(fakePage.getContent());
    }

 

 주목할 점은 given절의 freeBoardRepository.findAll() 메서드에 any()를 사용하지 않고 PageRequest객체를 직접 넣었다는 것이다. 이전 포스팅에서 객체를 직접 사용했을 때 결과가 제대로 반영되지 않았던 적이 있었다. 이유는 equals()와 hashCode()를 구현하지 않아서이다. 그러나 이번 PageRequest의 경우, 정적 팩터리 메서드를 사용해서 만들어진 구현체다. 따라서 해시코드 값 비교를 해도 같은 객체로 판별되기 때문에 그대로 사용해도 되는 것이다.

 

 위 코드에서 레파지토리는 fakePage를 반환한다. 이제 우리가 FreeBoardQueryService#readPagedEntity()를 통해 가져온 List<FreeBoard>가 fakePage에 담긴 내용과 같은지만 테스트하면 된다. 당연히 실패한다. 아직 해당 메서드를 정의하지 않았다.

    @Transactional(readOnly = true)
    public List<FreeBoard> readPagedEntity(int page) {
        return null;
    }

 

 여전히 실패하는 동작을 가지고 왔다. 한 줄 한 줄 채워보자. 먼저 레파지토리의 findAll()메서드에 넘겨줄 Pageable 값부터 만들어야한다.

    @Transactional(readOnly = true)
    public List<FreeBoard> readPagedEntity(int page) {
        Pageable pageable = PageRequest.ofSize(20).withPage(page);
        return null;
    }

 

 20이라는 매직 넘버는 추후 상수값으로 뺄 예정이다. 일단은 구현을 위해 저렇게 사용했다. 다음으로는 레파지토리에서 Page<FreeBoard>를 가져오자.

    @Transactional(readOnly = true)
    public List<FreeBoard> readPagedEntity(int page) {
        Pageable pageable = PageRequest.ofSize(20).withPage(page);
        Page<FreeBoard>freeBoardPage = freeBoardRepository.findAll(pageable);
        return null;
    }

 

 마지막으로 가져온 Page에서 List를 뽑아서 반환하면 되겠다.

    @Transactional(readOnly = true)
    public List<FreeBoard> readPagedEntity(int page) {
        Pageable pageable = PageRequest.ofSize(20).withPage(page);
        Page<FreeBoard>freeBoardPage = freeBoardRepository.findAll(pageable);
        return freeBoardPage.getContent();
    }

 

 이제 테스트를 수행하면 통과함을 확인할 수 있다. 그럼 다음으로 1번 로직의 테스트와 기능 구현을 하러 떠나보자.

    @Test
    @DisplayName("게시글 페이지 조회 요청 테스트")
    void getFreeBoardPageTest() throws Exception {
    
    }

 

 우리가 테스트 할 내용은 'free-board?page=0 이라는 uri로 get요청을 보냈을 때, Service에서 반환한 List를 적절히 반환하며, 이 때 상태코드는 200 OK 인가?'이다. 나는 여기서 크게 두 갈래로 테스트를 나누고자 했다.

  • Service의 List를 적절히 반환하는가?
  • 상태코드는 200 OK인가?

 이렇게 나눈 이유는, List의 내용들을 순회하면서 도는 테스트 코드를 작성하고 싶었기 때문이다. 일단은 given 절부터 만들었다.

    @Test
    @DisplayName("게시글 페이지 조회 요청 테스트")
    void getFreeBoardPageTest() throws Exception {
        // given
        int fakePage = 1;

        List<FreeBoardDto.Response> fakeResponseList = List.of(
                FreeBoardDto.Response.builder().boardId(1).title("1").content("1").createdAt("1").modifiedAt("1").build(),
                FreeBoardDto.Response.builder().boardId(2).title("2").content("2").createdAt("2").modifiedAt("2").build(),
                FreeBoardDto.Response.builder().boardId(3).title("3").content("3").createdAt("3").modifiedAt("3").build(),
                FreeBoardDto.Response.builder().boardId(4).title("4").content("4").createdAt("4").modifiedAt("4").build(),
                FreeBoardDto.Response.builder().boardId(5).title("5").content("5").createdAt("5").modifiedAt("5").build(),
                FreeBoardDto.Response.builder().boardId(5).title("6").content("6").createdAt("6").modifiedAt("6").build()
        );

        given(freeBoardService.getFreeBoardPage(anyInt())).willReturn(fakeResponseList);
    }

 

 FreeBoardService#getFreeBoardPage() 메서드에서 fakeResponseList를 반환하도록 만들었다. 이제 when, then 절을 작성할 차례다. 먼저 작성한 것은 List를 적절히 반환하는가? 에 대한 검증이다.

    @Test
    @DisplayName("게시글 페이지 조회 요청 테스트")
    void getFreeBoardPageTest() throws Exception {
        // given

        int fakePage = 1;

        List<FreeBoardDto.Response> fakeResponseList = List.of(
                FreeBoardDto.Response.builder().boardId(1).title("1").content("1").createdAt("1").modifiedAt("1").build(),
                FreeBoardDto.Response.builder().boardId(2).title("2").content("2").createdAt("2").modifiedAt("2").build(),
                FreeBoardDto.Response.builder().boardId(3).title("3").content("3").createdAt("3").modifiedAt("3").build(),
                FreeBoardDto.Response.builder().boardId(4).title("4").content("4").createdAt("4").modifiedAt("4").build(),
                FreeBoardDto.Response.builder().boardId(5).title("5").content("5").createdAt("5").modifiedAt("5").build(),
                FreeBoardDto.Response.builder().boardId(5).title("6").content("6").createdAt("6").modifiedAt("6").build()
        );

        given(freeBoardService.getFreeBoardPage(anyInt())).willReturn(fakeResponseList);

        // when // then
        for(int i = 0;i<fakeResponseList.size();i++){
            mockMvc.perform(
                            get("/free-board?page={page}", fakePage)
                                    .accept(MediaType.APPLICATION_JSON)
                                    .contentType(MediaType.APPLICATION_JSON)
                    ).andExpect(jsonPath("$", hasSize(fakeResponseList.size())))
                    .andExpect(jsonPath("$[%d].boardId",i).value(fakeResponseList.get(i).getBoardId()))
                    .andExpect(jsonPath("$[%d].title",i).value(fakeResponseList.get(i).getTitle()))
                    .andExpect(jsonPath("$[%d].content",i).value(fakeResponseList.get(i).getContent()))
                    .andExpect(jsonPath("$[%d].createdAt",i).value(fakeResponseList.get(i).getCreatedAt()))
                    .andExpect(jsonPath("$[%d].modifiedAt",i).value(fakeResponseList.get(i).getModifiedAt()));
        }
    }

 

 반환된 JSON 은 배열의 형태로 보내진다. 첫 번째 andExpect()에서는 배열의 크기가 fakeResponseList와 같은지를 검증한다. 굳이 여러 번 검증할 필요는 없지만, 적절히 List를 반환하는지에 대한 테스트에 포함되기 때문에 그냥 넣었다. 다음 줄부터는 JSON 배열의 각 요소의 값들이 fakeResponseList의 각 요소들의 값들과 같은지를 판단한다.

 

 다음으로는 HTTP 상태코드가 200 OK인지를 검증한다. 이 테스트까지 통과하면 해당 테스트를 기반으로 API 문서화를 진행해도 된다. 따라서 API 문서화에 대한 코드는 이 부분에 담았다.

        mockMvc.perform(
                        get("/free-board?page={page}", fakePage)
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                ).andExpect(status().isOk())
                .andDo(document(
                        "get-paged-free-board",
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        requestParameters(
                                parameterWithName("page").description("페이지 번호")
                        ),
                        responseFields(
                                List.of(
                                        fieldWithPath("[].boardId").type(JsonFieldType.NUMBER).description("게시글 식별자"),
                                        fieldWithPath("[].title").type(JsonFieldType.STRING).description("제목"),
                                        fieldWithPath("[].content").type(JsonFieldType.STRING).description("내용"),
                                        fieldWithPath("[].createdAt").type(JsonFieldType.STRING).description("작성일"),
                                        fieldWithPath("[].modifiedAt").type(JsonFieldType.STRING).description("수정일")
                                )
                        )
                ));

 

 주목할만한 부분은 requestParameters를 통해 해당 REST 메서드에는 Request Parameter가 전달될 것임을 보여준다는 것이다. 또한 responseFields의 List에 들어가는 값들이 배열의 형태로 담긴다. 해당 테스트를 기반으로 Controller의 메서드를 작성하자.

    @GetMapping
    public ResponseEntity<List<FreeBoardDto.Response>> getFreeBoardPage(@RequestParam(name = "page", defaultValue = "1") int pageNumber){
        List<FreeBoardDto.Response> responseDtoList = freeBoardService.getFreeBoardPage(pageNumber);
        return new ResponseEntity<>(responseDtoList, HttpStatus.OK);
    }

 

 로직에는 별로 중요한 부분이 없기는 하다. 그냥 서비스에서 다 처리해주면 그 결과값을 반환하기만 할 뿐이다. 주목할만한 부분은 @RequestParam 애너테이션이다. name 속성은 해당 Request Parameter의 이름 값을 반영하고, defaultValue 속성은 기본 값을 반영한다.

 

 테스트를 통과하기 위해서는 FreeBoardService#getFreeBoardPage() 메서드도 정의해야 한다. 만들러 가보자.

    List<FreeBoardDto.Response> getFreeBoardPage(int pageNumber);
    
/////////////////////////

    @Override
    @Transactional(readOnly = true)
    public List<FreeBoardDto.Response> getFreeBoardPage(int pageNumber) {
        int pageIndex = pageNumber - 1;
        List<FreeBoard> entityList = freeBoardQueryService.readPagedEntity(pageIndex);
        return entityList.stream()
                .map(freeBoardConverter::convertEntityToResponseDto)
                .collect(Collectors.toList());
    }

 

 Page의 인덱스는 0부터 시작한다. 그런데 일반적으로 페이지는 1번부터 시작한다. 따라서 pageIndex라는 변수를 사용해 넘어온 pageNumber에서 1을 빼고 하위 클래스에 해당 인덱스 값을 넘겨준다. 이후로 받아온 List를 순회하며 Response DTO로 변환한 List를 반환한다.

 

 이제 Controller 테스트를 수행하면 잘 통과함을 확인할 수 있다. 교차 검증을 위해 Postman으로도 수행해보았다.

 

 별도로 page값을 넘기지 않았음에도 defaultValue값이 들어가 1번 페이지가 들어간 모습이다. 당연히 ?page=1을 붙여도 정상적으로 작동한다.

 

 추가해볼만한 점은 전체 페이지 수를 넘었을 때를 예외처리 할 것인가? 정도가 되겠다. 굳이 필요한 기능은 아니라고 생각한다. 그냥 아무것도 안뜨게 하면 되지 뭘 또. 다만 1보다 작은 값이 pageNumber로 넘어왔을 때는 확실히 예외 처리를 해야한다. 인덱스가 0보다 작으면 사용할 수 없다. 해당 기능을 구현하기 전에 뭐부터 해야한다? 바로 테스트 구현이다. 드디어 상위 서비스의 테스트를 만들 필요성이 생겼다.

    @Test
    @DisplayName("페이지 번호가 1보다 작으면 예외처리한다.")
    void testGetFreeBoardPage() {
        int fakePageNumber = 0;
        assertThrows(RuntimeException.class, () -> freeBoardService.getFreeBoardPage(fakePageNumber));
    }

 

 아주 짧고 간결한 테스트 코드가 작성되었다. 이를 기반으로 pageNumber를 검증하는 메서드를 만들어보자.

    @Override
    @Transactional(readOnly = true)
    public List<FreeBoardDto.Response> getFreeBoardPage(int pageNumber) {
        verifyPageNumber(pageNumber);
        int pageIndex = pageNumber - 1;
        List<FreeBoard> entityList = freeBoardQueryService.readPagedEntity(pageIndex);
        return entityList.stream()
                .map(freeBoardConverter::convertEntityToResponseDto)
                .collect(Collectors.toList());
    }

    private void verifyPageNumber(int pageNumber) {
        if (pageNumber < MINIMUM_PAGE_NUMBER_VALUE.getValue()) {
            throw new RuntimeException("");
        }
    }

 

 verifyPageNumber 메서드를 통해 pageNumber 값을 검증한다. 만일 pageNumber의 최솟값보다 작으면 예외를 던진다. MINIMUM_PAGE_NUMBER_VALUE 상수는 이후 리뷰 게시판과 구인구직 게시판에서도 공통적으로 사용될 것이기 때문에 BoardEnums 라는 유틸리티 클래스를 만들어 공통으로 사용 가능하게 담았다. 아마 해당 클래스의 이름은 구현 과정 중에 바뀔 것이다.

 

 또한 현재는 RuntimeException을 던지는데, 추후 예외처리 공통화 과정에서 Exeption을 제작해서 대체할 예정이다.

 

 이렇게 게시글 페이지 조회 구현을 마쳤다. 아무래도 배열 테스트에 대한 공부가 이번 구현에서 가장 크게 배워간 점이 아닌가 싶다.

 

  전체 코드는 링크에서 확인할 수 있다.

반응형

댓글