테스트 관련 용어는 이전 포스팅을 참고하자.
이를 위한 테스트 주도 개발이 있다. 단순히 테스트를 기능 검사 체크리스트로 접근하는 것이 아닌 먼저 테스트를 만들고 해당 테스트를 통과하는 최소한의 코드 작성, 리팩토링을 반복하여 기능을 완성해 나가는 기법이다. 먼저 핵심 기능 테스트를 작성하여 의존관계를 파악하고, 필요한 코드만 작성하려고 노력하다 보면 코드 품질이 향상된다고 한다.
Spring Web MVC Framework를 이용해 기본적인 게시판 댓글 CRUD 기능을 테스트하며 구현해 본다. 웹 서비스는 외부 요청을 받아 처리하고 적절한 응답을 반환하는 Controller 계층, 비즈니스 로직을 구현하는 Service 계층, 데이터베이스를 조작하는 Repository 계층 구조를 가진다. 계층마다 책임이 다르기에 테스트해야 하는 부분이 다르다. 중요한 것부터 검증이 필요한 순서대로 테스트를 작성하면 되지만 개인적으로 핵심 의존 관계를 빠르게 파악하기 위해 Service 계층부터 작성해야 한다고 생각한다.
@Mock
으로 모방할 대상을 정하고, @InjectMocks
는 Mocking 된 객체를 테스트 대상 객체(CommentService)에 주입하기 위해 사용한다. @ExtendWith(MockitoExtension.class)
는 JUnit5에서 Mokito를 사용하겠다는 어노테이션이다.@ExtendWith(MockitoExtension.class)
public class CommentServiceTest {
@InjectMocks
private CommentService commentService;
@Mock
private CommentRepository commentRepository;
@Mock
private UserRepository userRepository;
@Mock
private ArticleRepository articleRepository;
}
Mokito.when() ~ .thenReturn()
을 통해 가짜 객체가 특정 메소드를 호출할 때 반환값을 지정할 수 있고, Mockito.when() ~ .thenThrow()
를 통해 에러를 발생시킬 수도 있다.Mokito.doThrow
, Mokito.doNothing
을 사용할 수 있다.when(articleRepository.findById(article.getId())).thenReturn(Optional.of(article));
Mockito.when(commentService.create(Mockito.eq(article.getId()), Mockito.any(RequestCreateComment.class)))
.thenThrow(new UserNotFoundException());
Mokito.verify ~ times()
를 통해 횟수 검증이 가능하다. times가 1인 경우 생략 가능하다.Mockito.verify(commentRepository).delete(comment1);
any(), eq(), argThat(), ...
)를 이용하여 메소드 호출 시 전달되는 인자를 유연하게 검증하는 것을 말한다.when(commentRepository.save(any(Comment.class))).thenReturn(comment1);
verify(mock).someMethod(eq(1));
@DisplayName("유효한 게시글, 유저로 댓글 생성한다")
@Test
void CreateComment_WithValidArticleAndUser_ReturnCreatedComment() {
//given
RequestCreateComment requestCreateComment = new RequestCreateComment(null, user2.getId(), "댓글 1");
when(articleRepository.findById(article.getId())).thenReturn(Optional.of(article));
when(userRepository.findById(user2.getId())).thenReturn(Optional.of(user2));
when(commentRepository.save(any(Comment.class))).thenReturn(comment1);
//when
ResponseCreateComment responseCreateComment = commentService.create(article.getId(), requestCreateComment);
//then
assertThat(responseCreateComment).isNotNull();
assertThat(responseCreateComment.id()).isEqualTo(1L);
assertThat(responseCreateComment.userId()).isEqualTo(user2.getId());
assertThat(responseCreateComment.content()).isEqualTo("댓글 1");
}
@DisplayName("존재하지 않는 게시글에 댓글 생성 시 CommentNotFoundException 발생한다")
@Test
void CreatedComment_WithNoneExistentArticle_ReturnCommentNotFoundException() {
//given
Long nonExistentArticleId = Long.MAX_VALUE;
RequestCreateComment requestCreateComment = new RequestCreateComment(null, user2.getId(), "댓글 1");
when(articleRepository.findById(nonExistentArticleId)).thenReturn(Optional.empty());
//when
//then
assertThatThrownBy(() -> {
commentService.create(nonExistentArticleId, requestCreateComment);
}).isInstanceOf(ArticleNotFoundException.class);
}
@DisplayName("존재하지 않는 유저가 댓글 생성 시 CommentNotFoundException 발생한다")
@Test
void CreatedComment_WithNoneExistentUser_ReturnCommentNotFoundException() {
//given
Long nonExistentUserId = Long.MAX_VALUE;
RequestCreateComment requestCreateComment = new RequestCreateComment(null, nonExistentUserId, "댓글 1");
when(articleRepository.findById(article.getId())).thenReturn(Optional.of(article));
when(userRepository.findById(nonExistentUserId)).thenReturn(Optional.empty());
//when
//then
assertThatThrownBy(() -> {
commentService.create(article.getId(), requestCreateComment);
}).isInstanceOf(UserNotFoundException.class);
}
@DisplayName("유효한 댓글 아이디로 특정 댓글 조회시 해당 댓글을 반환한다")
@Test
void FindComment_WithValidId_ReturnComment() {
//given
ResponseComment expected = new ResponseComment(1L, 2L, "닉네임 2", 1L, "제목 1", "댓글 1");
when(commentRepository.findById(comment1.getId())).thenReturn(Optional.of(comment1));
//when
ResponseComment responseComment = commentService.findCommentById(comment1.getId());
//then
assertThat(responseComment).isNotNull();
assertThat(responseComment).usingRecursiveComparison().isEqualTo(expected);
}
@DisplayName("유효한 게시글 아이디에 해당하는 댓글 조회시 해당하는 모든 댓글을 반환한다")
@Test
void findComments_WithValidArticleId_ReturnComments() {
//given
List<Comment> comments = Arrays.asList(comment1, comment2);
List<ResponseComment> expected = mapper.commentsToResponseComments(comments);
when(articleRepository.findById(article.getId())).thenReturn(Optional.of(article));
when(commentRepository.findByArticleId(article.getId())).thenReturn(comments);
//when
List<ResponseComment> actual = commentService.findCommentsByArticleId(article.getId())
.stream()
.toList();
//then
assertThat(actual.size()).isEqualTo(2);
assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
}
@DisplayName("모든 댓글 조회 시 모든 댓글을 반환한다")
@Test
void FindArticles_ReturnFoundArticles() {
//given
List<Comment> comments = Arrays.asList(comment1, comment2);
List<ResponseComment> expected = mapper.commentsToResponseComments(comments);
when(commentRepository.findAll()).thenReturn(comments);
//when
List<ResponseComment> actual = commentService.findAll()
.stream()
.toList();
//then
assertThat(actual.size()).isEqualTo(2);
assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
}
@DisplayName("유효한 정보로 댓글 수정 시 수정된 댓글을 반환한다")
@Test
void UpdateComment_ReturnUpdatedComment() {
//given
comment1 = new Comment(1L, user2, article, "댓글 수정 1");
ResponseComment expected = mapper.commentToResponseComment(comment1);
RequestUpdateComment requestUpdateComment = new RequestUpdateComment("댓글 수정 1", user2.getId());
when(commentRepository.findById(comment1.getId())).thenReturn(Optional.of(comment1));
when(commentRepository.save(any(Comment.class))).thenAnswer(invocation -> invocation.getArgument(0));
//when
ResponseComment updatedComment = commentService.update(comment1.getId(), requestUpdateComment);
//then
assertThat(updatedComment).isNotNull();
assertThat(updatedComment).usingRecursiveComparison().isEqualTo(expected);
}
@DisplayName("권한 없는 사용자가 댓글 수정 시 UnauthorizedAccessException 발생한다")
@Test
void UpdateComment_WithUnAuthorizedUser_ReturnUnauthorizedAccessException() {
//given
comment1 = new Comment(1L, user2, article, "댓글 수정 1");
ResponseComment expected = mapper.commentToResponseComment(comment1);
RequestUpdateComment requestUpdateComment = new RequestUpdateComment("댓글 수정 1", user1.getId());
when(commentRepository.findById(comment1.getId())).thenReturn(Optional.of(comment1));
//when
//then
assertThatThrownBy(() -> {
commentService.update(article.getId(), requestUpdateComment);
}).isInstanceOf(UserUnauthorizedAccessException.class);
}
@DisplayName("유효한 댓글 아이디로 댓글을 삭제한다")
@Test
void DeleteComment_WithValidId() {
//given
when(commentRepository.findById(comment1.getId())).thenReturn(Optional.of(comment1));
//when
commentService.delete(comment1.getId());
//then
Mockito.verify(commentRepository).delete(comment1);
}
@DisplayName("유효한 댓글 아이디가 아닐 경우 NotFoundExpcetion을 발생한다")
@Test
void DeleteComment_WithInvalidId_ReturnNotFoundException() {
//given
Long nonExistentArticleId = Long.MAX_VALUE;
when(commentRepository.findById(nonExistentArticleId)).thenReturn(Optional.empty());
//when
//then
assertThatThrownBy(() -> {
commentService.delete(nonExistentArticleId);
}).isInstanceOf(CommentNotFoundException.class);
}
private CommentMapper mapper = Mappers.getMapper(CommentMapper.class);
방법으로는 의존성을 주입할 수 없었다. 여러 해결 방법을 찾아보던 중 리플렉션으로 의존성을 주입하여 mapper에 대해서만 실제 객체를 사용할 수 있었다.private CommentMapper mapper = Mappers.getMapper(CommentMapper.class);
ReflectionTestUtils.setField(commentService, "mapper", mapper);