테스트 코드를 짜다 보면, 많은 수의 example data 를 생성해야 한다.
이번 포스트에서는 내가 테스트 코드를 학습하면서 접한 4가지 방법에 대해서 소개하려고 한다.
본격적으로 들어가기에 앞서 테스트하려는 대상부터 설명하겠다.
Comment 엔티티
package com.bizplus.boardsaturday.domain.entity;
import com.bizplus.boardsaturday.domain.common.BaseTimeEntity;
import com.bizplus.boardsaturday.domain.type.ActiveStatus;
import com.bizplus.boardsaturday.domain.type.DeleteStatus;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Comment extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
private String content;
@Enumerated(EnumType.STRING)
private ActiveStatus activeStatus;
@Enumerated(EnumType.STRING)
private DeleteStatus deleteStatus;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;
public Comment(String content,
ActiveStatus activeStatus,
DeleteStatus deleteStatus,
Post post,
Member member,
Comment parent) {
this.content = content;
this.activeStatus = activeStatus;
this.deleteStatus = deleteStatus;
this.post = post;
this.member = member;
this.parent = parent;
}
public void changeStatusOn() {
this.activeStatus = ActiveStatus.ACTIVE;
}
public void changeStatusOff() {
this.activeStatus = ActiveStatus.INACTIVE;
}
public void delete() {
this.deleteStatus = DeleteStatus.DELETED;
}
}
Comment는 일반적인 게시판의 댓글에 해당하는 엔티티이다.
Post(게시글), Member(작성자),
Parent(일반 댓글의 경우 null일 것이고, 대댓글이면 Comment)를 연관관계로 갖고 있다.
이러한 상황에서 Comment를 생성하는 service 코드를 테스트한다고 가정해보자.
CommentCreator
package com.bizplus.boardsaturday.application.component.comment;
import com.bizplus.boardsaturday.application.request.comment.CreateCommentRequest;
import com.bizplus.boardsaturday.domain.entity.Comment;
import com.bizplus.boardsaturday.domain.entity.Member;
import com.bizplus.boardsaturday.domain.entity.Post;
import com.bizplus.boardsaturday.domain.repository.CommentRepository;
import com.bizplus.boardsaturday.domain.repository.MemberRepository;
import com.bizplus.boardsaturday.domain.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityNotFoundException;
@Component
@RequiredArgsConstructor
@Transactional
public class CommentCreator {
private final CommentRepository commentRepository;
private final MemberRepository memberRepository;
private final PostRepository postRepository;
public Comment create(CreateCommentRequest request) {
Member member = memberRepository.findById(request.getMemberId())
.orElseThrow(() -> new EntityNotFoundException("회원을 찾을 수 없습니다."));
Post post = postRepository.findById(request.getPostId())
.orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다."));
Comment parent = null;
if (request.isReply()) {
parent = commentRepository.findById(request.getParentId())
.orElseThrow(() -> new EntityNotFoundException("부모 댓글을 찾을 수 없습니다."));
}
Comment comment = request.toEntity(member, post, parent);
return commentRepository.create(comment);
}
}
Comment를 생성하는 create() 코드이다.
CommentCreator의 create()를 테스트하려면
Member member, Post post, Comment parent를 생성해줘야만 한다.
1. 그냥 만들기
new 연산자를 사용하건, 빌더를 사용하건간에 그냥 만드는 것을 말한다.
예제를 살펴보면,
테스트
@Test
@DisplayName("댓글 생성 - Mock")
void CommentCreatorMockTest_create() {
// given
Member member = new Member(
1L,
"griotold",
"griotold@email.com",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING);
Post post = new Post(
1L,
"default title",
"default body",
null, null,
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING);
Comment comment = new Comment(
1L,
"default content",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING,
post,
member,
null);
CreateCommentRequest createCommentRequest
= new CreateCommentRequest(
memberId,
postId,
null,
"default content");
when(memberRepository.findById(any())).thenReturn(Optional.of(member));
when(postRepository.findById(any())).thenReturn(Optional.of(post));
when(commentRepository.create(any())).thenReturn(comment);
// when
Comment savedComment = commentCreator.create(createCommentRequest);
// then
assertThat(savedComment).isEqualTo(comment);
}
그냥 example data를 생성하면 코드가 너무 길어지는 문제가 발생한다.
2. 테스트 클래스가 example data를 생성하는 추상 클래스를 상속하는 방식
해당 강의에서 접한 방식이다.
example data를 생성하는 추상 클래스를 만들어서 상속하는 방식.
public abstract DummyObject {
protected Comment newComment(Member member, Post post) {
return new Comment(1L, "default content",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING,
post,
member,
null);
}
protected Comment newCommentWithParent(Member member, Post post) {
Comment parent = new Comment(1L, "default parent content",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING,
post,
member,
null);
return new Comment(2L, "default content",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING,
post,
member,
parent);
}
protected Post newPost() {
return new Post(1L, "default title",
"default body",
null, null,
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING);
}
protected Member newMember() {
return new Member(1L, "griotold",
"griotold@email.com",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING);
}
}
protected 접근제한자를 사용하여 해당 클래스를 상속하는 클래스만 메소드를 사용할 수 있도록 한다.
상속 받은 클래스만 사용하도록 제한을 준 것 까지는 긍정적이지만,
하나의 클래스가 모든 example data를 생성하고 있어서 좋은 방법은 아니라고 생각한다.
일종의 God Class로 볼 수 있는데, 하지 말아야 할 코딩 방식이라고 생각한다.
3. Test Data Builder 패턴
Builder 패턴에서 파생된 패턴
example data를 만들어주는 빌더를 선언한 Util 클래스를 사용하는 방식이다.
빌더를 사용하다보니 엔티티에서 빌더를 선언해줘야 사용할 수 있다.
출처는 위의 강의이다
public class CommentTestDataBuilder {
public static Comment.CommentBuilder normalCommentBuilder(Post post, Member member) {
return Comment
.builder()
.id(1L)
.content("default content)
.activeStatus(ActiveStatus.ACTIVE)
.deleteStatus(DeleteStatus.EXISTING)
.post(post)
.member(member)
.parent(null);
}
public static Comment.CommentBuilder replyCommentBuilder(Post post,
Member member,
Comment parent) {
return Comment
.builder()
.id(1L)
.content("default content)
.activeStatus(ActiveStatus.ACTIVE)
.deleteStatus(DeleteStatus.EXISTING)
.post(post)
.member(member)
.parent(parent);
}
}
테스트 코드
@Test
@DisplayName("댓글 생성 - Mock")
void CommentCreatorMockTest_create() {
// given
Member member = MemberTestDataBuilder.memberBuilder.build();
Post post = PostTestDataBuilder.postBuilder.build();
Comment comment = CommentTestDataBuilder.normalCommentBuilder.build();
CreateCommentRequest createCommentRequest
= CreateCommenRequestBuilder.requestBuilder(member.getId(), post.getId());
when(memberRepository.findById(any())).thenReturn(Optional.of(member));
when(postRepository.findById(any())).thenReturn(Optional.of(post));
when(commentRepository.create(any())).thenReturn(comment);
// when
Comment savedComment = commentCreator.create(createCommentRequest);
// then
assertThat(savedComment).isEqualTo(comment);
}
테스트 코드에서는 제공받은 빌더를 사용하여 example data를 생성한다.
해당 패턴의 장점은 속성의 변화를 주고 싶을때 쉽게 덮어쓸 수 있다는 점이다.
예를 들어, comment의 content를 바꿔주고 싶으면 .content("새로운 내용") 을 추가해주면 되는 것이다.
단점은 고정되어 있지 않다보니 손쉽게 바뀔 수 있다는 점이다.
손쉽게 바뀔 수 있는 것은 장점으로 볼 수도 있고 단점이 되기도 한다.
4. Fixture Object 패턴 (Object Mother)
마지막으로 Fixture Object 패턴이다.
테스트 코드의 example data를 fixture라고 표현하기도 한다.
그래서 Fixture Object라고 불리는 것으로 보인다.
fixture를 생성하는 Util 클래스를 만들어서 테스트 코드에 사용하는 방식이다.
해당 패턴을 처음 접한 것도 아래 강의였다.
다른 말로 Object Mother이라고도 불린다.
example data를 생성하는 일종의 "엄마"라고 보는 것이다.
https://martinfowler.com/bliki/ObjectMother.html
위의 게시글을 읽어보니까 마틴파울러가 Thoughtworks 회사의 프로젝트에서 처음 명명했다고 한다.
package com.bizplus.boardsaturday.application.component.comment.model;
import com.bizplus.boardsaturday.domain.entity.Comment;
import com.bizplus.boardsaturday.domain.entity.Member;
import com.bizplus.boardsaturday.domain.entity.Post;
import com.bizplus.boardsaturday.domain.type.ActiveStatus;
import com.bizplus.boardsaturday.domain.type.DeleteStatus;
public class CommentFixture {
public static Comment create(Member member, Post post) {
return new Comment(1L, "default content",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING,
post,
member,
null);
}
public static Comment createCommentWithParent(Member member, Post post) {
Comment parent = new Comment(1L, "default parent content",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING,
post,
member,
null);
return new Comment(2L, "default content",
ActiveStatus.ACTIVE,
DeleteStatus.EXISTING,
post,
member,
parent);
}
}
생성하고자 하는 객체 뒤에 Fixture를 붙여줌으로 Fixture Object 패턴임을 보여준다.
테스트
@Test
@DisplayName("댓글 생성 - Mock")
void CommentCreatorMockTest_create() {
// given
// Fixture Object 패턴 적용
Member member = MemberFixture.create();
Post post = PostFixture.create();
Comment comment = CommentFixture.create(member, post);
CreateCommentRequest createCommentRequest = CreateCommentRequestFixture.create(member.getId(), post.getId());
when(memberRepository.findById(any())).thenReturn(Optional.of(member));
when(postRepository.findById(any())).thenReturn(Optional.of(post));
when(commentRepository.create(any())).thenReturn(comment);
// when
Comment savedComment = commentCreator.create(createCommentRequest);
// then
assertThat(savedComment).isEqualTo(comment); // 또는 필요한 필드를 검증
}
5. 정리
이번 포스트에서는 테스트 코드에 example data를 생성하는 4가지 방법을 알아보았다.
모두 사용해본 결과, Test Data Builder와 Fixture Object 패턴이 쓸만했다.
테스트를 좀 더 작성하다보면 나한테 맞는 방식을 발견할 수 있을 거라 생각한다.
이상.
'테스트' 카테고리의 다른 글
DisplayName을 섬세하게 (4) | 2024.10.21 |
---|---|
Spring Boot 에서 Repository 테스트 하기 (0) | 2024.03.04 |