Controller, Repository 계층 단위테스트 하기 (2/2)

junto·2024년 2월 15일
0

spring

목록 보기
6/30
post-thumbnail

Repository 계층 단위테스트

  • Repository 계층 테스트는 데이터베이스가 제대로 동작하는지 확인해야 한다. 직접 작성한 쿼리 또는 복잡한 쿼리에 대해서만 검사하면 된다고 생각한다. 또한 댓글 수정처럼 Database가 제공하는 기능이 아니라 Service 계층에서 구현되는 기능에 대해선 중복 테스트를 만들지 않도록 한다.

별도의 테스트 데이터베이스 사용

  • 복잡한 쿼리, 직접 작성한 쿼리가 없었기 때문에 Repository 계층 테스트는 너무나 간단했다. 하지만 테스트용 데이터베이스를 따로 관리하고 싶었고, H2 Database가 아닌 실제 운영 환경과 똑같은 MySQL을 사용하고 싶었기 때문에 docker를 이용해 운영 서버 DB와 테스트 서버 DB를 격리하였다.
services:
  juny_db:
    user: 501:20
    image: mysql:8.0.33
    ports:
      - 13306:3306
    container_name: juny_db
    volumes:
      - $HOME/JunyProjects/MysqlDocker/JunyBoard/Develop:/var/lib/mysql:rw
      - $HOME/JunyProjects/MysqlDocker/JunyBoard/my.cnf:/etc/my.cnf:rw
      - $HOME/JunyProjects/MysqlDocker/JunyBoard/Script/Develop:/docker-entrypoint-initdb.d
  test_db:
    user: 501:20
    image: mysql:8.0.33
    ports:
      - 13307:3306
    container_name: test_db
    volumes:
      - $HOME/JunyProjects/MysqlDocker/JunyBoard/Test:/var/lib/mysql:rw
      - $HOME/JunyProjects/MysqlDocker/JunyBoard/my.cnf:/etc/my.cnf:rw
      - $HOME/JunyProjects/MysqlDocker/JunyBoard/Script/Test:/docker-entrypoint-initdb.d
  • 테스트를 위한 더미 데이터를 만들어야 했는데, Bean을 통해 주입하는 방법과 sql문으로 주입하는 방법이 있었다. sql문으로 더미 데이터를 주입하는 방식이 의존관계를 복잡하게 하지 않는 단위테스트 목적과 부합한다고 생각하여 다음 sql문으로 더미 데이터를 주입했다.
    • 더미 데이터를 주입하기 전에 table을 만들어야 한다. ddl-auto: update로 jpa가 만들어주는 table을 사용할 수 있다. test-resources-application.yml에 테스트 환경을 위한 별도의 yaml파일을 작성할 수 있다.

Repository 테스트 어노테이션

  • @DataJpaTest
    • JPA repository에 대한 테스트를 지원한다. 서비스나 컨트롤러 같은 다른 컴포넌트는 스캔 대상이 아니기에 효율적으로 repository 계층에 대한 테스트가 가능하다.
    • 기본적으로 내장형 데이터베이스(h2 database)를 사용하기에 테스트용 데이터베이스를 별도로 사용한다면 설정에 추가해야 한다.
    • @Transaction 어노테이션을 가지고 있어서 단위테스트를 실행 후 데이터를 테스트 이전으로 롤백한다.
  • @AutoConfigureTestDatabase
    • 테스트에서 사용할 설정을 제어하는 역할을 한다. replace = AutoConfigureTestDatabase.Replace.NONE 옵션을 주게되면 내장형 데이터베이스를 사용하지 않고, .yml에 정의된 데이터베이스를 사용할 수 있다.

Controller 계층 단위테스트

  • Controller 계층은 웹 외부 요청을 받아 처리하고 적절한 응답을 반환해야 하는 책임이 있다.

1. Mocking

  • 그림에서 보듯 Controller 계층을 테스트하면서 Service 로직에 의존한다. Service 계층이 제대로 동작하는지 확인하고 싶은 것이 아니기 때문에 Service 로직은 Mocking하여 가짜 객체를 사용하여 스터빙한다.
  • WebMvcTest
    • 스프링 MVC 컨트롤러를 테스트하기 위해 사용된다. 이전에는 필요한 컨텍스트를 로드하는 코드가 필요했지만, 해당 어노테이션으로 대상 컨트롤러와 MVC 관련 컴포넌트 등만 자동으로 로드한다.
  • MockBean
    • 스프링 애플리케이션 컨텍스트에 등록된 빈을 모의 객체(Mock)로 교체하는 데 사용된다. CommentService의 실제 구현은 필요 없기 때문에 모의 객체로 변경한다.

2. MockMvc

  • MockMvc는 웹 애플리케이션 서버 없이 MVC 테스트를 할 수 있는 환경을 제공하는 모의 객체이다. 이를 통해 HTTP 요청과 응답을 모방하여 컨트롤러의 동작을 검증할 수 있다.
    • mockMvc.perform() 메서드를 통해 HTTP 요청을 흉내낼 수 있다.
      • 클라이언트 요청을 어떻게 처리할지 contentType, content, ... 통해 옵션을 정하며, 클라이언트가 응답받는 형식을 정하기 위해 accept를 사용한다.
    • post(), get(), put(), delete()를 사용하며 ResultAction 객체를 반환한다. 이 객체는 andExpect(ResultMatcher matcher) 메서드를 사용하여 상태 코드, 응답 내용, 헤더 등을 검증한다.

댓글 서비스 CRUD 구현 (Controller)

1. 게시글 생성

@DisplayName("유효한 게시글, 유저로 댓글 생성 시 성공한다")
@Test
void CreateComment_WithValidArticleAndUser_ReturnSuccess() throws Exception {
    //given
    RequestCreateComment requestCreateCommentForm = new RequestCreateComment(null, 2L, "댓글 1");
    ResponseCreateComment expected = new ResponseCreateComment(1L, 2L, "댓글 1");
    when(commentService.create(Mockito.eq(article.getId()), Mockito.any(RequestCreateComment.class))).thenReturn(expected);

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/api/articles/{articleId}/comments", article.getId())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(requestCreateCommentForm)));

    //then
    resultActions
            .andExpect(MockMvcResultMatchers.status().isCreated())
            .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(expected.id()))
            .andExpect(MockMvcResultMatchers.jsonPath("$.userId").value(expected.userId()))
            .andExpect(MockMvcResultMatchers.jsonPath("$.content").value(expected.content()));
}

@DisplayName("존재하지 않는 게시글에 댓글 생성 시 CommentNotFoundException 발생한다")
@Test
void CreatedComment_WithNoneExistentArticle_ReturnCommentNotFoundException() throws Exception {
    //given
    Long nonExistentArticleId = Long.MAX_VALUE;
    RequestCreateComment requestCreateCommentForm = new RequestCreateComment(null, 2L, "댓글 1");
    when(commentService.create(Mockito.eq(nonExistentArticleId), Mockito.any(RequestCreateComment.class)))
            .thenThrow(new CommentNotFoundException());

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/api/articles/{articleId}/comments", nonExistentArticleId)
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(requestCreateCommentForm)));

    //then
    resultActions.andExpect(MockMvcResultMatchers.status().isNotFound())
            .andExpect(result -> assertThat(result.getResolvedException() instanceof CommentNotFoundException))
            .andExpect(result -> assertThat(result.getResolvedException().getMessage()).isEqualTo("존재하지 않는 댓글입니다."));
}

@DisplayName("존재하지 않는 유저가 댓글 생성 시 CommentNotFoundException 발생한다")
@Test
void CreatedComment_WithNoneExistentUser_ReturnCommentNotFoundException() throws Exception {
    //given
    Long nonExistentUserId = Long.MAX_VALUE;
    RequestCreateComment requestCreateCommentForm = new RequestCreateComment(null, nonExistentUserId, "댓글 1");
    when(commentService.create(Mockito.eq(article.getId()), Mockito.any(RequestCreateComment.class)))
            .thenThrow(new UserNotFoundException());

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/api/articles/{articleId}/comments", article.getId())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(requestCreateCommentForm)));

    //then
    resultActions.andExpect(MockMvcResultMatchers.status().isNotFound())
            .andExpect(result -> assertThat(result.getResolvedException()).isInstanceOf(UserNotFoundException.class))
            .andExpect(result -> assertThat(result.getResolvedException().getMessage()).isEqualTo("존재하지 않는 사용자입니다."));
}

2. 게시글 조회

@DisplayName("유효한 댓글 아이디로 특정 댓글 조회 시 해당 댓글을 반환한다")
@Test
void FindCommentById_WithValidId_ReturnComment() throws Exception {
    //given
    ResponseComment expected = new ResponseComment(1L, 2L, "user1", article.getId(), article.getTitle(), "댓글 1");
    when(commentService.findCommentById(1L)).thenReturn(expected);

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/comments/{id}", 1L)
            .accept(MediaType.APPLICATION_JSON));

    //then
    resultActions
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(expected.id()))
            .andExpect(MockMvcResultMatchers.jsonPath("$.userId").value(expected.userId()))
            .andExpect(MockMvcResultMatchers.jsonPath("$.userNickname").value(expected.userNickname()))
            .andExpect(MockMvcResultMatchers.jsonPath("$.articleId").value(expected.articleId()))
            .andExpect(MockMvcResultMatchers.jsonPath("$.articleTitle").value(expected.articleTitle()))
            .andExpect(MockMvcResultMatchers.jsonPath("$.content").value(expected.content()));
}

@DisplayName("존재하지 않는 댓글 조회 시 CommentNotFoundException 발생한다")
@Test
void FindArticle_WithNonexistentId_ReturnNotFoundException() throws Exception {
    //given
    Long nonExistentCommentId = Long.MAX_VALUE;
    when(commentService.findCommentById(nonExistentCommentId))
            .thenThrow(new CommentNotFoundException());

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/comments/{id}", nonExistentCommentId)
            .accept(MediaType.APPLICATION_JSON));

    //then
    resultActions
            .andExpect(MockMvcResultMatchers.status().isNotFound())
            .andExpect(result -> assertThat(result.getResolvedException()).isInstanceOf(CommentNotFoundException.class))
            .andExpect(result -> assertThat(result.getResolvedException().getMessage()).isEqualTo("존재하지 않는 댓글입니다."));
}

@DisplayName("유효한 게시글 아이디에 해당하는 댓글 조회시 해당하는 모든 댓글을 반환한다")
@Test
void findCommentsByArticleId_WithValidArticleId_ReturnComments() throws Exception {
    //given
    List<Comment> comments = Arrays.asList(comment1, comment2);
    List<ResponseComment> expected = mapper.commentsToResponseComments(comments);
    System.out.println("expected.toString() = " + expected.toString());
    when(commentService.findCommentsByArticleId(article.getId())).thenReturn(expected);

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/articles/{aritlceId}/comments", article.getId())
            .accept(MediaType.APPLICATION_JSON));

    //then
    resultActions
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(result -> {
                String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
                List<ResponseComment> actual = objectMapper.readValue(json, new TypeReference<List<ResponseComment>>() {
                });
                assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
            });
}

@DisplayName("모든 댓글 조회 시 모든 댓글을 반환한다")
@Test
void FindArticles_ReturnFoundArticles() throws Exception {
    //given
    List<Comment> comments = Arrays.asList(comment1, comment2);
    List<ResponseComment> expected = mapper.commentsToResponseComments(comments);
    when(commentService.findAll()).thenReturn(expected);

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/comments"));

    //then
    resultActions
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(result -> {
                String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
                List<ResponseComment> actual = objectMapper.readValue(json, new TypeReference<List<ResponseComment>>() {
                });
                assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
            });
}

3. 게시글 수정

@DisplayName("유효한 정보로 댓글 수정 시 수정된 댓글을 반환한다")
@Test
void UpdateComment_ReturnUpdatedComment() throws Exception {
    //given
    comment1 = new Comment(1L, user2, article, "댓글 수정 1");
    ResponseComment expected = mapper.commentToResponseComment(comment1);
    RequestUpdateComment requestUpdateComment = new RequestUpdateComment("댓글 수정 1", user2.getId());

    when(commentService.update(comment1.getId(), requestUpdateComment)).thenReturn(expected);

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.patch("/api/comments/{id}", comment1.getId())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(requestUpdateComment)));

    //then
    resultActions
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(result -> {
                String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
                ResponseComment responseComment = objectMapper.readValue(json, new TypeReference<ResponseComment>() {
                });
                assertThat(responseComment).usingRecursiveComparison().isEqualTo(expected);
            });
}

@DisplayName("권한 없는 사용자가 댓글 수정 시 UnauthorizedAccessException 발생한다")
@Test
void UpdateComment_WithUnAuthorizedUser_ReturnUnauthorizedAccessException() throws Exception {
    //given
    Long nonExistentUserId = Long.MAX_VALUE;
    comment1 = new Comment(1L, user2, article, "댓글 수정 1");
    ResponseComment expected = mapper.commentToResponseComment(comment1);
    RequestUpdateComment requestUpdateComment = new RequestUpdateComment("댓글 수정 1", nonExistentUserId);
    when(commentService.update(comment1.getId(), requestUpdateComment)).thenThrow(
            new UserUnauthorizedAccessException(ErrorCode.USER_UNAUTHORIZED_ACCESS)
    );

    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.patch("/api/comments/{id}", comment1.getId())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(requestUpdateComment)));

    //then
    resultActions
            .andExpect(MockMvcResultMatchers.status().isUnauthorized())
            .andExpect(result -> assertThat(result.getResolvedException()).isInstanceOf(UserUnauthorizedAccessException.class))
            .andExpect(result -> assertThat(result.getResolvedException().getMessage()).isEqualTo("권한 없는 사용자입니다."));
}

4. 게시글 삭제

DisplayName("유효한 댓글 아이디로 댓글을 삭제한다")
@Test
void DeleteComment_WithValidId() throws Exception {
    //given


    //when
    ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.delete("/api/comments/{id}", comment1.getId())
            .accept(MediaType.APPLICATION_JSON));


    //then
    resultActions.andExpect(MockMvcResultMatchers.status().isOk());
    Mockito.verify(commentService).delete(comment1.getId());
}
profile
꾸준하게

0개의 댓글