원래 어느정도 기능이 구현되어 있던 간단한 게시판 프로젝트에 TDD를 이용해서 새로운 기능을 추가해보았다
* 기존의 회사 컴은 java 1.8 버전을 사용중이라 이 프로젝트의 실행을 위해서는 java-sdk를 version17로 변경해준다!
File → Settings → Build Tools → Gradle → Gradle JVM
내가 추가해볼 기능은 바로 댓글 좋아요 기능이다!
이때, commentLike 도메인 정도는 먼저 작성하려고 했었는데 찾아보니, TDD의 경우 도메인 코드보다 테스트 코드가 먼저!
즉, CommentLike 도메인 클래스를 먼저 만드는 것이 아니라, "이 클래스가 어떤 동작을 해야 하는지"를 테스트 코드로 먼저 정의하는 것이 출발점이라고 한다
테스트 작성 (Red 단계)
아직 CommentLike 클래스가 없더라도, "이런 기능이 있어야 한다"라는 테스트를 작성한다
이때, 고민이 되었던 부분이, 나는 다른 기능들을 개발할 경우에 모두 도메인 객체 생성 제약(@NoArgsConstructor(access = PROTECTED)를 설정해두었다. 따라서, JPA엔티티나 도메인 모델을 외부에서 함부로 new하지 못하게 해서 불변성과 일관성을 보장하려고 했는데, 테스트 코드에서도 객체 생성을 해야하다보니 어떻게 할 지가 고민이 되었다. 이때 선택지는 2가지라고 한다
- 정적 팩토리 메서드 사용
실제로 가장 많이 사용하는 방법이라고 한다
엔티티는 protected 생성자 그대로 두고, 대신 정적 팩토리 메서드를 만들어서 테스트/도메인 코드 모두 이걸 쓰게 하는 방식이다. 나의 경우 이전에 처음 시작할 때 바로 롬북을 쓰지 않고 정적 팩토리 메소드로 개발을 시작했어서 이미 다 만들어져 있었다
//정적 팩토리 메서드
public static Member create(String studentNumber, String nickname, String school, String email, String password, MemberStatus active) {
return new Member(studentNumber, nickname, school, email, password, MemberStatus.ACTIVE);
}
- Test Fixture - 테스트 전용 헬퍼 클래스
테스트 코드 패키지 안에 MemberFixture 같은 유틸을 두고 생성을 도와주는 정적 메서드로만 객체 생성
public class MemberFixture {
public static Member createMember(String name) {
return Member.create(name);
}
}
장점 : 도메인 변경 시에도 테스트 코드만 수정하면 됨
그럼 이제 첫 번째 테스트 코드를 작성해준다. 사용자가 댓글에 좋아요를 눌렀을 때, commentLike가 생성되는 로직을 검증하는 간단한 코드이다.
package efub.assignment.comment.domain;
import efub.assignment.community.board.domain.Board;
import efub.assignment.community.comment.domain.Comment;
import efub.assignment.community.comment.domain.CommentLike;
import efub.assignment.community.member.domain.Member;
import efub.assignment.community.member.domain.MemberStatus;
import efub.assignment.community.post.domain.Post;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class CommentLikeTest {
@Test
public void 사용자는_댓글에_좋아요를_누를수가_있다() {
//given
Member member = Member.create("2222222", "tester", "ewha", "test@test.com", "000000", MemberStatus.ACTIVE);
Board board = Board.create("컴공게시판", "공지사항", "desc", member);
Post post = Post.create(board, member, true, "컴공댕들 안녕");
Comment comment = Comment.create(post, true, "테스트댓글", member);
//when
CommentLike commentLike = CommentLike.create(member, comment);
//then
assertEquals(member, commentLike.getMember());
assertEquals(comment, commentLike.getComment());
}
}
의도된 RED
아직 commentLike 클래스가 없기 때문에 에러가 발생한다 → 의도된 RED
따라서, 이 에러를 해결해주기 위하여 commentLike 도메인 클래스를 생성해준다
CommentLike 도메인 클래스 생성
package efub.assignment.community.comment.domain;
import efub.assignment.community.global.domain.BaseEntity;
import efub.assignment.community.member.domain.Member;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Entity
public class CommentLike extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long commentLikeId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member", nullable = false)
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment", nullable = false)
private Comment comment;
private CommentLike(Member member, Comment comment) {
this.member = member;
this.comment = comment;
}
public static CommentLike create(Member member, Comment comment) {
return new CommentLike(null, member, comment);
}
}
이때, board → post → comment → commentLike로 참조가 이어지기 때문에 각각 모두 임시로 생성해주어야 하위 항목을 생성할 수가 있다!
이제 실행해보면, 이렇게 테스트가 통과되는 것을 확인해볼 수가 있다

그럼 이제 사용자가 같은 댓글에 중복 좋아요를 남길 수 없도록 하는 로직을 구현해보도록 하자
이전에 각 테스트에 앞서 필요한 객체들을 생성해주어야 헀는데, 이것의 코드 중복을 피하기 위해서 @BeforeEach를 이용해서
@BeforeEach
public void generateObjects(){
Member member = Member.create(...);
Board board = Board.create(...);
Post post = Post.create(board, member, ...);
Comment comment = Comment.create(post, ...);
}
이렇게 작성해주었더니, "cannot resolve symbol" 에러가 발생
● 에러 원인 : member, board, post, comment가 generateObjects 메서드의 지역변수로만 존재하고, 실제 테스트 메서드에서 접근할 수가 없음
● 해결 방법 : 테스트 클래스의 필드로 선언하고, @BeforeEach에서 초기화 해야함
public class CommentLikeTest {
private Member member;
private Board board;
private Post post;
private Comment comment;
... 생략
// 필요한 객체들 생성
@BeforeEach
public void setUp(){
Member member = Member.create("2222222", "tester", "ewha", "test@test.com", "000000", MemberStatus.ACTIVE);
Board board = Board.create("컴공게시판", "공지사항", "desc", member);
Post post = Post.create(board, member, true, "컴공댕들 안녕");
Comment comment = Comment.create(post, true, "테스트댓글", member);
}
}


그럼 이제 테스트 코드를 돌려보면~

이런식으로 에러가 발생함을 알 수가 있다
이는 아직 commentLikeService가 존재하지 않기 때문 → 따라서 commentLikeService 를 작성해보자

이렇게 코드를 작성해주고 다시 테스트를 실행해준다
계속 NPE가 발생해서 물어봤더니 추가적인 에러를 알 수가 있었다...
// 필요한 객체들 생성
@BeforeEach
public void setUp(){
member = Member.create("2222222", "tester", "ewha", "test@test.com", "000000", MemberStatus.ACTIVE);
board = Board.create("컴공게시판", "공지사항", "desc", member);
post = Post.create(board, member, true, "컴공댕들 안녕");
comment = Comment.create(post, true, "테스트댓글", member);
}
이렇게 해야된다. 원래대로 했으면, 다시 객체를 생성함으로써 앞에서 선언했던 걸 덮어씌우는 형식이 되므로 에러가 발생한다.
또한, 주입한 서비스가 여러 의존성을 가지고 있다면, 가지고 있는 모든 의존성을 @Mock으로 추가해줘야한다.!

계속 유효성 검사에서 에러가 났는데 이것이 DB를 사용하는 로직이라서 그런 것 같다. 따라서 Service만 테스트하려면 Mocking으로 commentValidation과 findAllByComment 동작을 가짜로 만들어주어야 한다
또한, DB에서 member, comment 를 생성할 경우, PK는 자동으로 생성되도록 설정이 되어있는데 그 때문에 test 하는 도중에는 각 객체의 id값이 null이므로 에러가 발생한다. 이때, 이 값을 테스트를 위하여 직접 넣어줄 수 있는데
ReflectionTestUtils.setField(comment, "commentId", 1L);
이렇게 사용하면 된다
ReflectionTestUtils.setField
- Spring에서 제공하는 유틸로, private 또는 sertter가 없는 필드에 강제로 값을 넣을 때 사용
- 엔티티는 보통 id가 DB에서 자동 생성되는데, 단위 테스트에서는 DB연결이 없으니까 강제로 세팅
이렇게 해서 테스트를 다시 실행해보면, 엥......?!?

● 문제 원인 : given(commentLikeRepository.findAllByComment(comment)).willReturn(List.of(...)) 처럼 정적(stub)반환을 설정하면, 서비스가 createCommentLike()에서 findAllByComment()를 호출해도 항상 같은(미리 만든) 리스트만 돌아옴. 따라서 mock의 상태는 변하지 않기 때문에(= 저장 효과가 반영되지 않음) 테스트 결과가 기대와 달라짐
● 해결 방법 : Mockito로 '상태변화' 흉내내기
findAllByComment()와 save()가 동작하는 작은 in-memory 리스트를 만들어서, findAllByComment()는 그 리스트를 읽게 하고, save()는 리스트에 추가하도록 함
수정된 CommentLikeTest
package efub.assignment.comment.domain;
import efub.assignment.community.board.domain.Board;
import efub.assignment.community.comment.domain.Comment;
import efub.assignment.community.comment.domain.CommentLike;
import efub.assignment.community.comment.dto.request.CommentLikeRequestDTO;
import efub.assignment.community.comment.repository.CommentLikeRepository;
import efub.assignment.community.comment.service.CommentLikeService;
import efub.assignment.community.comment.service.CommentService;
import efub.assignment.community.member.domain.Member;
import efub.assignment.community.member.domain.MemberStatus;
import efub.assignment.community.member.service.MemberService;
import efub.assignment.community.post.domain.Post;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class CommentLikeTest {
@InjectMocks
CommentLikeService commentLikeService;
@Mock
CommentLikeRepository commentLikeRepository;
@Mock
MemberService memberService;
@Mock
CommentService commentService;
private Member member;
private Board board;
private Post post;
private Comment comment;
private List<CommentLike> commentLikes; //테스트용 in-memory store
@Test
public void 사용자는_댓글에_좋아요를_누를수가_있다() {
//given
// Member member = Member.create("2222222", "tester", "ewha", "test@test.com", "000000", MemberStatus.ACTIVE);
// Board board = Board.create("컴공게시판", "공지사항", "desc", member);
// Post post = Post.create(board, member, true, "컴공댕들 안녕");
// Comment comment = Comment.create(post, true, "테스트댓글", member);
//when
CommentLike commentLike = CommentLike.create(member, comment);
//then
assertEquals(member, commentLike.getMember());
assertEquals(comment, commentLike.getComment());
}
@Test
public void 같은_사용자가_중복_좋아요_불가() {
//Given
//in-memory store 초기화
commentLikes = new ArrayList<>();
ReflectionTestUtils.setField(comment, "commentId", 1L);
CommentLikeRequestDTO requestDTO = CommentLikeRequestDTO.builder()
.member(member)
.comment(comment)
.build();
//commentValidation이 comment 반환하도록 설정
given(commentService.commentValidation(comment.getCommentId())).willReturn(comment);
// given(commentLikeRepository.findAllByComment(comment))
// .willReturn(List.of(CommentLike.create(member, comment)));
//findAllByComment는 현재 commentLikes의 복사본(현재 상태) 반환
given(commentLikeRepository.findAllByComment(comment))
.willAnswer(invocation -> new ArrayList<>(commentLikes));
//save가 호출되면 commentLikes에 추가하고 저장된 객체 리턴
when(commentLikeRepository.save(any(CommentLike.class)))
.thenAnswer(invocation -> {
CommentLike saved = invocation.getArgument(0);
commentLikes.add(saved);
return saved;
});
//When
commentLikeService.createCommentLike(requestDTO);
commentLikeService.createCommentLike(requestDTO); //중복 좋아요 시도
//Then
assertEquals(1, commentLikeService.getCommentLikesCnt(comment));
// save는 실제로 한 번만 호출되었어야 함 (중복이면 save하지 않도록 서비스가 구현 될 필요 有)
verify(commentLikeRepository, atMost(1)).save(any(CommentLike.class));
}
// 필요한 객체들 생성
@BeforeEach
public void setUp(){
member = Member.create("2222222", "tester", "ewha", "test@test.com", "000000", MemberStatus.ACTIVE);
board = Board.create("컴공게시판", "공지사항", "desc", member);
post = Post.create(board, member, true, "컴공댕들 안녕");
comment = Comment.create(post, true, "테스트댓글", member);
}
}
테스트를 위한 in-memory store용 리스트를 생성해주고, save가 호출되면 이 리스트에 저장을 하고 저장된 객체를 리턴해줌. 그 후, service단에서 중복 호출을 방지할 것이므로 save함수 자체가 1번만 실행되어야하므로 이를 verify를 통해서 검증한다
됐다!!

그럼 이 테스트를 통과시키기 위해서 중복 방지 로직을 작성한다.
이때 set을 이용해서 구현하는 방식이랑, 일반적으로 조건문으로 분기하는 방법이 있는데, set을 사용하는 방식이 DB 조회를 덜 할 수 있는 것 같다. 하지만 일단은 DB로 갯수 조회하는 형식으로 구현을 했다
중복 로직을 구현한 CommentLikeService.java 코드
@Transactional
public String createCommentLike(CommentLikeRequestDTO requestDTO) {
// //1. 유효성 검사
// Comment comment = commentService.commentValidation(requestDTO.getComment().getCommentId());
// Member member = memberService.findMemberByMemberId(requestDTO.getMember().getMemberId());
// if (comment == null || member == null) {
// throw new IllegalArgumentException("댓글 좋아요 중 에러 발생");
// }
Comment comment = requestDTO.getComment();
Member member = requestDTO.getMember();
//중복 방지 로직
if (getCommentLikesPresent(comment, member)) {
return "이미 좋아요를 누른 댓글입니다";
}
//2. 좋아요 생성
CommentLike commentLike = CommentLike.create(member, comment);
commentLikeRepository.save(commentLike);
return "좋아요가 생성되었습니다";
}
public boolean getCommentLikesPresent(Comment comment, Member member) {
//1. 유효성 검사
Comment cmt = commentService.commentValidation(comment.getCommentId());
Member mem = memberService.findMemberByMemberId(member.getMemberId());
if(cmt == null) {
throw new IllegalArgumentException("유효하지 않은 댓글입니다");
}
//2. 해당 댓글에 좋아요 눌른 이력이 있는 지 반환
Optional<CommentLike> isLiked = commentLikeRepository.findByCommentAndMember(comment, member);
if(isLiked.isEmpty()) return false;
else return true;
}
코드를 작성하다보니, 숫자를 반환하는 것이 아니라 하나라도 있으면 true, 없으면 false 를 반환해서 존재 유무를 확인하고 false일 경우에만 save 메소드를 호출하는 형식으로 변환하였다
변환된 로직에 맞게 테스트 코드도 변경해줌
package efub.assignment.comment.domain;
import efub.assignment.community.board.domain.Board;
import efub.assignment.community.comment.domain.Comment;
import efub.assignment.community.comment.domain.CommentLike;
import efub.assignment.community.comment.dto.request.CommentLikeRequestDTO;
import efub.assignment.community.comment.repository.CommentLikeRepository;
import efub.assignment.community.comment.service.CommentLikeService;
import efub.assignment.community.comment.service.CommentService;
import efub.assignment.community.member.domain.Member;
import efub.assignment.community.member.domain.MemberStatus;
import efub.assignment.community.member.service.MemberService;
import efub.assignment.community.post.domain.Post;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class CommentLikeTest {
@InjectMocks
CommentLikeService commentLikeService;
@Mock
CommentLikeRepository commentLikeRepository;
@Mock
MemberService memberService;
@Mock
CommentService commentService;
private Member member;
private Board board;
private Post post;
private Comment comment;
private List<CommentLike> commentLikes; //테스트용 in-memory store
@Test
public void 사용자는_댓글에_좋아요를_누를수가_있다() {
//given
// Member member = Member.create("2222222", "tester", "ewha", "test@test.com", "000000", MemberStatus.ACTIVE);
// Board board = Board.create("컴공게시판", "공지사항", "desc", member);
// Post post = Post.create(board, member, true, "컴공댕들 안녕");
// Comment comment = Comment.create(post, true, "테스트댓글", member);
//when
CommentLike commentLike = CommentLike.create(member, comment);
//then
assertEquals(member, commentLike.getMember());
assertEquals(comment, commentLike.getComment());
}
@Test
public void 같은_사용자가_중복_좋아요_불가() {
//Given
//in-memory store 초기화
commentLikes = new ArrayList<>();
ReflectionTestUtils.setField(comment, "commentId", 1L);
CommentLikeRequestDTO requestDTO = CommentLikeRequestDTO.builder()
.member(member)
.comment(comment)
.build();
//commentValidation이 comment 반환하도록 설정
given(commentService.commentValidation(comment.getCommentId())).willReturn(comment);
given(memberService.findMemberByMemberId(member.getMemberId())).willReturn(member);
// given(commentLikeRepository.findAllByComment(comment))
// .willReturn(List.of(CommentLike.create(member, comment)));
//findAllByComment는 현재 commentLikes의 복사본(현재 상태) 반환
// given(commentLikeRepository.findAllByComment(comment))
// .willAnswer(invocation -> new ArrayList<>(commentLikes));
//findByCommentAndMember -> 상태에 따라서 Optional 반환
given(commentLikeRepository.findByCommentAndMember(comment, member))
.willAnswer(invocation -> commentLikes.stream()
.filter(cL -> cL.getComment().equals(comment) && cL.getMember().equals(member))
.findFirst());
//save가 호출되면 commentLikes에 추가하고 저장된 객체 리턴
when(commentLikeRepository.save(any(CommentLike.class)))
.thenAnswer(invocation -> {
CommentLike saved = invocation.getArgument(0);
commentLikes.add(saved);
return saved;
});
//When
String msg1 = commentLikeService.createCommentLike(requestDTO);
String msg2 = commentLikeService.createCommentLike(requestDTO); //중복 좋아요 시도
//Then
assertEquals("좋아요가 생성되었습니다", msg1);
assertEquals("이미 좋아요를 누른 댓글입니다", msg2);
assertEquals(1, commentLikes.size());
// // save는 실제로 한 번만 호출되었어야 함 (중복이면 save하지 않도록 서비스가 구현 될 필요 有)
// verify(commentLikeRepository, atMost(1)).save(any(CommentLike.class));
}
// 필요한 객체들 생성
@BeforeEach
public void setUp(){
member = Member.create("2222222", "tester", "ewha", "test@test.com", "000000", MemberStatus.ACTIVE);
board = Board.create("컴공게시판", "공지사항", "desc", member);
post = Post.create(board, member, true, "컴공댕들 안녕");
comment = Comment.create(post, true, "테스트댓글", member);
}
}
통과한 것을 확인할 수가 있다

사용된 개념 정리
📌 JUnit 5 관련
@ExtendWith(MockitoExtension.class)
▷ JUnit5 테스트 확장 기능(Extension)을 추가하는 어노테이션
▷ MockitoExtension을 등록하면, 테스트 실행 시 @Mock, @InjectMocks 등을 자동 초기화
📌 Mockito 관련
@Mock
▷ Mock 객체(가짜 객체)를 생성해주는 어노테이션
▷ 여기서는 CommentLikeRepository, CommentService를 Mock으로 만들어서 진짜 DB 접근을 막고, 원하는 반환값을 직접 설정함
@InjectMocks
▷ Mock이 아닌 실제 객체를 생성하면서, 필요한 의존성(@Mock으로 만든 객체들)을 주입해줌
▷ 여기서는 commentLikeService가 실제로 만들어지고, 그 안의 commentLikeRepository, commentService가 Mock으로 채워짐
given(...)willReturn(...)
▷ Mockito BDD 스타일 문법
▷ 특정 메서드 호출이 있을 때 정해진 값을 반환하도록 설정
given(commentService.commentValidation(comment.getCommentId()))
.willReturn(comment);
given(...)willAnser(...)
▷ willReturn은 정해진 값만 주지만, willAnswer는 호출될 때마다 동적으로 로직을 실행시킴
▷ 여기서는 in-memory likeStore를 매번 반환하도록 했음
given(commentLikeRepository.findAllByComment(comment))
.willAnswer(invocation -> new ArrayList<>(likeStore));
when(...)thenAnswer(...)
▷ given과 같은 기능인데, BDD 스타일이 아닌 전통적인 Mockito 문법
▷ 여기서는 save() 흉내내기 위해 사용
when(commentLikeRepository.save(any(CommentLike.class)))
.thenAnswer(invocation -> {
CommentLike saved = invocation.getArgument(0);
likeStore.add(saved);
return saved;
});
→ save가 호출되면 전달된 객체를 likeStore에 추가하고 그대로 반환
verify(mock, times(n))
▷ Mock 객체의 메서드가 몇 번 호출됐는지 검증
▷ 예시 - save()가 최대 1번 호출되었는지 체크
verify(commentLikeRepository, atMost(1)).save(any(CommentLike.class));
코드 리뷰 반영
1. 문자열을 그대로 비교하는 것은 권장 X → .contains("좋아요")와 같이 부분 검증으로 변환
//수정 전
assertEquals("좋아요가 생성되었습니다", msg1);
assertEquals("이미 좋아요를 누른 댓글입니다", msg2);
//코드리뷰 반영
assertTrue(msg1.contains("생성되었습니다"));
assertTrue(msg2.contains("이미 좋아요"));
'Java' 카테고리의 다른 글
| 테스트 하기 쉬운 코드 && 테스트 범위와 종류 (0) | 2025.10.14 |
|---|---|
| TDD 시작하기 (0) | 2025.09.09 |
| 스프링 데이터 JPA (2) | 2025.08.17 |
| 나를 위한 클래스와 객체, 상속 부분 내용 정리 (0) | 2023.05.16 |
| 끝말잇기 게임 만들기 (0) | 2023.05.15 |